add undo/redo

Hunter
Friday, October 2nd, 2026 at 11:52 PM

.gitignore+1-2
index.html+1 
readme.md+2 
resources/history.jsnew
resources/keyboard.js+3-4
resources/main.js+4 
resources/multi-select.js -52
resources/state.js -2
resources/storage.js+7-7
resources/task-element.js+3-3
sw.js+1 

.gitignore
  -CLAUDE.md
1 +tests/
index.html
14  	<link rel="stylesheet" href="resources/main.css">
15  	<script src="resources/state.js"></script>
16  	<script src="resources/storage.js"></script>
17 +	<script src="resources/history.js"></script>
18  	<script src="resources/task-model.js"></script>
19  	<script src="resources/multi-select.js"></script>
20  	<script src="resources/navigation.js"></script>
readme.md
35  - `⌘ + C` copy selected task(s) and their subtasks
36  - `⌘ + X` cut selected task(s) and their subtasks
37  - `⌘ + V` paste copied task(s) and their subtasks
38 +- `⌘ + Z` undo
39 +- `⌘ + Shift + Z` redo
40  
41  ### reorganization
42  - `Shift + ⬆️/⬇️` reposition selected task(s) within their current level
resources/history.js
  1 +// History: app-wide undo/redo of per-task changes and view moves
  2 +
  3 +var editHistory = {
  4 +	undoStack: [],
  5 +	redoStack: [],
  6 +	committedIndex: null,
  7 +	committedView: null,
  8 +	treeChanged: false,
  9 +	checkPending: false,
 10 +	typingTaskId: null,
 11 +	lastTypingTime: 0,
 12 +	typingPauseMs: 1000,
 13 +	maxEdits: 10
 14 +};
 15 +
 16 +function createTaskRecord(task) {
 17 +	return {
 18 +		text: task.text,
 19 +		state: task.state,
 20 +		selectedSubtaskId: task.selectedSubtaskId || null,
 21 +		subtaskIds: task.subtasks.map(t => t.id)
 22 +	};
 23 +}
 24 +
 25 +function indexTaskTree(root) {
 26 +	var index = new Map();
 27 +	(function visit(task) {
 28 +		index.set(task.id, createTaskRecord(task));
 29 +		task.subtasks.forEach(visit);
 30 +	})(root);
 31 +	return index;
 32 +}
 33 +
 34 +function liveTaskMap() {
 35 +	var tasks = new Map();
 36 +	(function visit(task) {
 37 +		tasks.set(task.id, task);
 38 +		task.subtasks.forEach(visit);
 39 +	})(state.taskPath[0]);
 40 +	return tasks;
 41 +}
 42 +
 43 +function subtaskIdsMatch(ids, subtasks) {
 44 +	if (ids.length !== subtasks.length) return false;
 45 +	for (var i = 0; i < ids.length; i++) {
 46 +		if (ids[i] !== subtasks[i].id) return false;
 47 +	}
 48 +	return true;
 49 +}
 50 +
 51 +function diffSubtaskIds(oldIds, newIds) {
 52 +	var start = 0;
 53 +	while (start < oldIds.length && start < newIds.length && oldIds[start] === newIds[start]) start++;
 54 +	var oldEnd = oldIds.length;
 55 +	var newEnd = newIds.length;
 56 +	while (oldEnd > start && newEnd > start && oldIds[oldEnd - 1] === newIds[newEnd - 1]) {
 57 +		oldEnd--;
 58 +		newEnd--;
 59 +	}
 60 +	return {
 61 +		before: { start: start, ids: oldIds.slice(start, oldEnd) },
 62 +		after: { start: start, ids: newIds.slice(start, newEnd) }
 63 +	};
 64 +}
 65 +
 66 +function diffTaskRecords(old, record) {
 67 +	var before = {};
 68 +	var after = {};
 69 +	for (var field of ['text', 'state', 'selectedSubtaskId']) {
 70 +		if (old[field] !== record[field]) {
 71 +			before[field] = old[field];
 72 +			after[field] = record[field];
 73 +		}
 74 +	}
 75 +	if (old.subtaskIds !== record.subtaskIds) {
 76 +		var splice = diffSubtaskIds(old.subtaskIds, record.subtaskIds);
 77 +		before.subtaskSplice = splice.before;
 78 +		after.subtaskSplice = splice.after;
 79 +	}
 80 +	return { before: before, after: after };
 81 +}
 82 +
 83 +function collectTreeChanges() {
 84 +	var index = editHistory.committedIndex;
 85 +	var changes = [];
 86 +	var created = [];
 87 +	var visitedExisting = 0;
 88 +	(function visit(task) {
 89 +		var old = index.get(task.id);
 90 +		if (!old) {
 91 +			var record = createTaskRecord(task);
 92 +			created.push([task.id, record]);
 93 +			changes.push({ id: task.id, before: null, after: record });
 94 +		} else {
 95 +			visitedExisting++;
 96 +			var sameSubtasks = subtaskIdsMatch(old.subtaskIds, task.subtasks);
 97 +			if (old.text !== task.text || old.state !== task.state
 98 +				|| old.selectedSubtaskId !== (task.selectedSubtaskId || null) || !sameSubtasks) {
 99 +				var record = createTaskRecord(task);
100 +				if (sameSubtasks) record.subtaskIds = old.subtaskIds;
101 +				var diff = diffTaskRecords(old, record);
102 +				changes.push({ id: task.id, before: diff.before, after: diff.after });
103 +				index.set(task.id, record);
104 +			}
105 +		}
106 +		task.subtasks.forEach(visit);
107 +	})(state.taskPath[0]);
108 +
109 +	if (visitedExisting < index.size) {
110 +		var live = liveTaskMap();
111 +		index.forEach((record, id) => {
112 +			if (!live.has(id)) changes.push({ id: id, before: record, after: null });
113 +		});
114 +		for (var change of changes) {
115 +			if (change.after === null) index.delete(change.id);
116 +		}
117 +	}
118 +	for (var entry of created) {
119 +		index.set(entry[0], entry[1]);
120 +	}
121 +	return changes;
122 +}
123 +
124 +function applyTaskChanges(changes, side) {
125 +	var tasks = liveTaskMap();
126 +	var linking = [];
127 +	for (var change of changes) {
128 +		var record = change[side];
129 +		var otherRecord = side === 'before' ? change.after : change.before;
130 +		if (record === null) {
131 +			tasks.delete(change.id);
132 +			continue;
133 +		}
134 +		if (otherRecord === null) {
135 +			tasks.set(change.id, { id: change.id, subtasks: [] });
136 +		}
137 +		var task = tasks.get(change.id);
138 +		if ('text' in record) task.text = record.text;
139 +		if ('state' in record) task.state = record.state;
140 +		if ('selectedSubtaskId' in record) task.selectedSubtaskId = record.selectedSubtaskId;
141 +		if ('subtaskIds' in record || 'subtaskSplice' in record) {
142 +			linking.push({ task: task, record: record, otherRecord: otherRecord });
143 +		}
144 +	}
145 +	for (var link of linking) {
146 +		if ('subtaskIds' in link.record) {
147 +			link.task.subtasks = link.record.subtaskIds.map(id => tasks.get(id));
148 +		} else {
149 +			var splice = link.record.subtaskSplice;
150 +			var removedCount = link.otherRecord.subtaskSplice.ids.length;
151 +			link.task.subtasks.splice(splice.start, removedCount, ...splice.ids.map(id => tasks.get(id)));
152 +		}
153 +	}
154 +	return tasks;
155 +}
156 +
157 +function captureView() {
158 +	var el = document.activeElement;
159 +	if (!el || !el.classList || !el.classList.contains('task-text')) return null;
160 +	return {
161 +		pathIds: state.taskPath.map(t => t.id),
162 +		focusedId: el.closest('.task-container').dataset.id,
163 +		caret: getCaretOffset(el),
164 +		multi: state.multiSelectedIds.length > 1 ? {
165 +			anchorId: state.multiSelectAnchorId,
166 +			ids: state.multiSelectedIds.slice(),
167 +			offsets: Object.assign({}, state.multiCaretOffsets),
168 +			ranges: JSON.parse(JSON.stringify(state.multiSelectRanges))
169 +		} : null
170 +	};
171 +}
172 +
173 +function viewLocation(view) {
174 +	return view.pathIds.join('/') + '|' + view.focusedId + '|' + (view.multi ? view.multi.ids.join(',') : '');
175 +}
176 +
177 +function isSelectionOnlyChange(change) {
178 +	return change.before !== null && change.after !== null
179 +		&& Object.keys(change.after).every(field => field === 'selectedSubtaskId');
180 +}
181 +
182 +function isTextOnlyChange(change) {
183 +	return change.before !== null && change.after !== null
184 +		&& Object.keys(change.after).every(field => field === 'text');
185 +}
186 +
187 +function trimUndoStack() {
188 +	var edits = editHistory.undoStack.filter(entry => entry.isEdit).length;
189 +	while (edits > editHistory.maxEdits) {
190 +		if (editHistory.undoStack.shift().isEdit) edits--;
191 +	}
192 +}
193 +
194 +function initHistory() {
195 +	editHistory.committedIndex = indexTaskTree(state.taskPath[0]);
196 +	editHistory.committedView = captureView();
197 +}
198 +
199 +function scheduleHistoryCheck() {
200 +	editHistory.treeChanged = true;
201 +	scheduleViewCheck();
202 +}
203 +
204 +function scheduleViewCheck() {
205 +	if (editHistory.checkPending) return;
206 +	editHistory.checkPending = true;
207 +	queueMicrotask(checkHistory);
208 +}
209 +
210 +function checkHistory() {
211 +	editHistory.checkPending = false;
212 +	if (!editHistory.committedIndex) return;
213 +	var view = captureView() || editHistory.committedView;
214 +	var moved = viewLocation(view) !== viewLocation(editHistory.committedView);
215 +	if (!editHistory.treeChanged && !moved) {
216 +		editHistory.committedView = view;
217 +		return;
218 +	}
219 +	editHistory.treeChanged = false;
220 +	var changes = collectTreeChanges();
221 +	if (changes.length === 0 && !moved) {
222 +		editHistory.committedView = view;
223 +		return;
224 +	}
225 +
226 +	var now = Date.now();
227 +	var isTyping = !moved && changes.every(isTextOnlyChange);
228 +	var continuesTyping = isTyping
229 +		&& view.focusedId === editHistory.typingTaskId
230 +		&& now - editHistory.lastTypingTime < editHistory.typingPauseMs;
231 +	if (continuesTyping) {
232 +		mergeTypingChanges(editHistory.undoStack[editHistory.undoStack.length - 1], changes, view);
233 +	} else {
234 +		editHistory.undoStack.push({
235 +			changes: changes,
236 +			before: editHistory.committedView,
237 +			after: view,
238 +			isEdit: !changes.every(isSelectionOnlyChange)
239 +		});
240 +		trimUndoStack();
241 +		editHistory.redoStack = [];
242 +	}
243 +	editHistory.typingTaskId = isTyping ? view.focusedId : null;
244 +	editHistory.lastTypingTime = now;
245 +	editHistory.committedView = view;
246 +}
247 +
248 +function mergeTypingChanges(entry, changes, view) {
249 +	for (var change of changes) {
250 +		var existing = entry.changes.find(c => c.id === change.id);
251 +		if (existing) {
252 +			existing.after.text = change.after.text;
253 +		} else {
254 +			entry.changes.push(change);
255 +		}
256 +	}
257 +	entry.after = view;
258 +}
259 +
260 +function undoHistory(task) {
261 +	stepHistory(editHistory.undoStack, editHistory.redoStack, 'before', task);
262 +}
263 +
264 +function redoHistory(task) {
265 +	stepHistory(editHistory.redoStack, editHistory.undoStack, 'after', task);
266 +}
267 +
268 +function stepHistory(fromStack, toStack, side, task) {
269 +	var entry = fromStack.pop();
270 +	if (!entry) {
271 +		state.multiSelectedIds.length > 1 ? shakeAllSelected() : applyShakeAnimation(task.id);
272 +		return;
273 +	}
274 +	toStack.push(entry);
275 +	var sameLevel = entry[side].pathIds.join('/') === state.taskPath.map(t => t.id).join('/');
276 +	var tasks = applyTaskChanges(entry.changes, side);
277 +	restoreView(entry[side], tasks, entry.isEdit || !sameLevel);
278 +	editHistory.typingTaskId = null;
279 +	scheduleSave();
280 +	collectTreeChanges();
281 +	editHistory.treeChanged = false;
282 +	editHistory.committedView = captureView();
283 +}
284 +
285 +function restoreView(view, tasks, redraw) {
286 +	clearMultiSelect();
287 +	state.taskPath = view.pathIds.map(id => tasks.get(id));
288 +	if (view.multi) {
289 +		state.multiSelectAnchorId = view.multi.anchorId;
290 +		state.multiSelectedIds = view.multi.ids.slice();
291 +		state.multiCaretOffsets = Object.assign({}, view.multi.offsets);
292 +		state.multiSelectRanges = JSON.parse(JSON.stringify(view.multi.ranges));
293 +	}
294 +	if (redraw) renderCurrentView();
295 +	selectAndFocusTask(tasks.get(view.focusedId), view.caret);
296 +	if (view.multi) applyMultiSelectHighlights();
297 +}
resources/keyboard.js
208  	} else if ((e.key === 'a' || e.key === 'A') && cmd && !e.shiftKey && !e.altKey && hasMultiSelect) {
209  		e.preventDefault();
210  		selectAllMultiSelected(task);
    -	// Cmd+Z / Cmd+Shift+Z during multi-select: app-level undo/redo (native
    -	// history would only touch the focused line, so it's blocked)
    -	} else if ((e.key === 'z' || e.key === 'Z') && cmd && !e.altKey && hasMultiSelect) {
211 +	// Cmd+Z / Cmd+Shift+Z: app-wide undo/redo
212 +	} else if ((e.key === 'z' || e.key === 'Z') && cmd && !e.altKey) {
213  		e.preventDefault();
    -		e.shiftKey ? redoMultiEdit(task) : undoMultiEdit(task);
214 +		e.shiftKey ? redoHistory(task) : undoHistory(task);
215  	}
216  }
resources/main.js
 62  		}
 63  	});
 64  
 65 +	document.addEventListener('keydown', checkHistory, true);
 66 +
 67  	// File save/open handlers
 68  	document.addEventListener('keydown', handleSave);
 69  	document.addEventListener('keydown', handleOpen);

109  			}
110  		}
111  		renderSimCarets();
112 +		scheduleViewCheck();
113  	});
114  
115  	// hide simulated carets while the window is inactive, like native ones

132  	setInitialTheme();
133  	renderCurrentView();
134  	selectFirstSubtask();
135 +	initHistory();
136  
137  	// re-pin the active caret's scroll once layout settles and once fonts load
138  	requestAnimationFrame(rescrollActiveCaret);
resources/multi-select.js
  5  	state.multiSelectedIds = [];
  6  	state.multiCaretOffsets = {};
  7  	state.multiSelectRanges = {};
    -	state.multiUndoStack = [];
    -	state.multiRedoStack = [];
  8  	updateMultiRangeStyles();
  9  	document.querySelectorAll('.sim-caret').forEach(el => el.remove());
 10  	var focused = document.activeElement;

113  // Backspace/Delete during multi-select: simulate deletion on every selected
114  // line (including the focused one) so native deletion can't split a grapheme
115  function deleteAcrossMultiSelection(focusedTask, taskInput, direction) {
    -	pushMultiUndo();
116  	var focusedOffset = getCaretOffset(taskInput);
117  	if (focusedOffset != null) state.multiCaretOffsets[focusedTask.id] = focusedOffset;
118  	for (var t of getMultiSelectedTasks()) {

139  	}
140  }
141  
    -// Cmd+Z / Cmd+Shift+Z: per-session undo/redo of multi-line edits; snapshots
    -// are pushed before each mutation (focusedOldText covers input events, where
    -// the focused line has already changed)
    -function captureMultiSnapshot(focusedId, focusedOldText) {
    -	var texts = {};
    -	for (var t of getMultiSelectedTasks()) {
    -		texts[t.id] = t.id === focusedId ? focusedOldText : t.text;
    -	}
    -	return {
    -		texts: texts,
    -		offsets: Object.assign({}, state.multiCaretOffsets),
    -		ranges: Object.assign({}, state.multiSelectRanges)
    -	};
    -}
    -
    -function pushMultiUndo(focusedId, focusedOldText) {
    -	state.multiUndoStack.push(captureMultiSnapshot(focusedId, focusedOldText));
    -	state.multiRedoStack = [];
    -}
    -
    -function undoMultiEdit(focusedTask) {
    -	var entry = state.multiUndoStack.pop();
    -	if (!entry) return;
    -	state.multiRedoStack.push(captureMultiSnapshot());
    -	applyMultiSnapshot(entry, focusedTask);
    -}
    -
    -function redoMultiEdit(focusedTask) {
    -	var entry = state.multiRedoStack.pop();
    -	if (!entry) return;
    -	state.multiUndoStack.push(captureMultiSnapshot());
    -	applyMultiSnapshot(entry, focusedTask);
    -}
    -
    -function applyMultiSnapshot(entry, focusedTask) {
    -	for (var t of getMultiSelectedTasks()) {
    -		if (typeof entry.texts[t.id] === 'string') {
    -			t.text = entry.texts[t.id];
    -			syncMultiTaskText(t);
    -		}
    -	}
    -	state.multiCaretOffsets = entry.offsets;
    -	state.multiSelectRanges = entry.ranges || {};
    -	var input = document.querySelector(`.task-container[data-id="${focusedTask.id}"] .task-text`);
    -	if (input) setCaretOffset(input, clampCaret(focusedTask.text, state.multiCaretOffsets[focusedTask.id]));
    -	renderSimCarets();
    -	scheduleSave();
    -}
    -
142  // Cmd+A: native select-all on the focused line plus stored full-line ranges
143  // on the others; the next edit then replaces each line's selection
144  function selectAllMultiSelected(focusedTask) {
resources/state.js
19  	multiSelectedIds: [],
20  	multiCaretOffsets: {},
21  	multiSelectRanges: {},
   -	multiUndoStack: [],
   -	multiRedoStack: [],
22  };
23  
24  function generateId() {
resources/storage.js
  5  		clearTimeout(state.saveTimer);
  6  	}
  7  	state.saveTimer = setTimeout(saveTasksToLocalStorage, 1000);
  8 +	scheduleHistoryCheck();
  9  }
 10  
 11  function saveTasksToLocalStorage() {

120  		reader.onload = function(e) {
121  			try {
122  				var newRootTask = deserializeTaskTree(e.target.result);
    -				if (confirm('Are you sure you want to overwrite the existing task tree?')) {
    -					state.rootTask = newRootTask;
    -					state.currentTask = state.rootTask;
    -					state.taskPath = [state.currentTask];
    -					renderCurrentView();
    -					saveTasksToLocalStorage();
    -				}
123 +				state.rootTask = newRootTask;
124 +				state.currentTask = state.rootTask;
125 +				state.taskPath = [state.currentTask];
126 +				renderCurrentView();
127 +				scheduleHistoryCheck();
128 +				saveTasksToLocalStorage();
129  			} catch (error) {
130  				alert(`Error importing task tree: ${error.message}`);
131  			}
resources/task-element.js
233  	taskInput.addEventListener('keyup', keyupHandler);
234  	taskInput.addEventListener('keydown', handleCopyCutPaste);
235  	taskInput.addEventListener('paste', (e) => e.preventDefault());
236 +	taskInput.addEventListener('beforeinput', (e) => {
237 +		if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') e.preventDefault();
238 +	});
239  	taskInput.addEventListener('input', (e) => {
240  		var oldText = task.text;
241  		// Normalize: strip newlines a paste/IME may introduce, and convert

249  		task.text = taskInput.textContent;
250  		// Propagate edits to all other multi-selected tasks
251  		if (state.multiSelectedIds.length > 1 && state.multiSelectedIds.includes(task.id)) {
    -			// snapshot before mirroring; the focused line already changed, so
    -			// its pre-edit text comes from oldText
    -			pushMultiUndo(task.id, oldText);
252  			var focusedOffset = getCaretOffset(taskInput);
253  			if (focusedOffset != null) state.multiCaretOffsets[task.id] = focusedOffset;
254  			var otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id);
sw.js
 6  	'resources/main.css',
 7  	'resources/state.js',
 8  	'resources/storage.js',
 9 +	'resources/history.js',
10  	'resources/task-model.js',
11  	'resources/multi-select.js',
12  	'resources/navigation.js',