floq 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +13 -0
- package/README.md +13 -0
- package/dist/i18n/en.d.ts +16 -0
- package/dist/i18n/en.js +10 -1
- package/dist/i18n/ja.js +10 -1
- package/dist/ui/App.js +132 -64
- package/dist/ui/SplashScreen.js +1 -1
- package/dist/ui/components/HelpModal.js +3 -2
- package/dist/ui/components/KanbanBoard.js +128 -46
- package/dist/ui/components/SearchResults.js +2 -2
- package/dist/ui/history/HistoryContext.d.ts +29 -0
- package/dist/ui/history/HistoryContext.js +44 -0
- package/dist/ui/history/HistoryManager.d.ts +56 -0
- package/dist/ui/history/HistoryManager.js +137 -0
- package/dist/ui/history/commands/ConvertToProjectCommand.d.ts +19 -0
- package/dist/ui/history/commands/ConvertToProjectCommand.js +37 -0
- package/dist/ui/history/commands/CreateCommentCommand.d.ts +18 -0
- package/dist/ui/history/commands/CreateCommentCommand.js +23 -0
- package/dist/ui/history/commands/CreateTaskCommand.d.ts +18 -0
- package/dist/ui/history/commands/CreateTaskCommand.js +24 -0
- package/dist/ui/history/commands/DeleteCommentCommand.d.ts +17 -0
- package/dist/ui/history/commands/DeleteCommentCommand.js +26 -0
- package/dist/ui/history/commands/DeleteTaskCommand.d.ts +18 -0
- package/dist/ui/history/commands/DeleteTaskCommand.js +51 -0
- package/dist/ui/history/commands/LinkTaskCommand.d.ts +20 -0
- package/dist/ui/history/commands/LinkTaskCommand.js +37 -0
- package/dist/ui/history/commands/MoveTaskCommand.d.ts +25 -0
- package/dist/ui/history/commands/MoveTaskCommand.js +43 -0
- package/dist/ui/history/commands/index.d.ts +7 -0
- package/dist/ui/history/commands/index.js +7 -0
- package/dist/ui/history/index.d.ts +6 -0
- package/dist/ui/history/index.js +8 -0
- package/dist/ui/history/types.d.ts +26 -0
- package/dist/ui/history/types.js +4 -0
- package/dist/ui/history/useHistory.d.ts +24 -0
- package/dist/ui/history/useHistory.js +31 -0
- package/package.json +1 -1
|
@@ -14,10 +14,12 @@ import { t, fmt } from '../../i18n/index.js';
|
|
|
14
14
|
import { useTheme } from '../theme/index.js';
|
|
15
15
|
import { isTursoEnabled } from '../../config.js';
|
|
16
16
|
import { VERSION } from '../../version.js';
|
|
17
|
+
import { useHistory, CreateTaskCommand, MoveTaskCommand, LinkTaskCommand, CreateCommentCommand, DeleteCommentCommand, } from '../history/index.js';
|
|
17
18
|
const COLUMNS = ['todo', 'doing', 'done'];
|
|
18
19
|
export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
|
|
19
20
|
const theme = useTheme();
|
|
20
21
|
const { exit } = useApp();
|
|
22
|
+
const history = useHistory();
|
|
21
23
|
const [mode, setMode] = useState('normal');
|
|
22
24
|
const [inputValue, setInputValue] = useState('');
|
|
23
25
|
const [currentColumnIndex, setCurrentColumnIndex] = useState(0);
|
|
@@ -87,32 +89,42 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
|
|
|
87
89
|
setTaskComments(comments);
|
|
88
90
|
}, []);
|
|
89
91
|
const addCommentToTask = useCallback(async (task, content) => {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
const commentId = uuidv4();
|
|
93
|
+
const command = new CreateCommentCommand({
|
|
94
|
+
comment: {
|
|
95
|
+
id: commentId,
|
|
96
|
+
taskId: task.id,
|
|
97
|
+
content: content.trim(),
|
|
98
|
+
createdAt: new Date(),
|
|
99
|
+
},
|
|
100
|
+
description: i18n.tui.commentAdded || 'Comment added',
|
|
96
101
|
});
|
|
102
|
+
await history.execute(command);
|
|
97
103
|
setMessage(i18n.tui.commentAdded || 'Comment added');
|
|
98
104
|
await loadTaskComments(task.id);
|
|
99
|
-
}, [i18n.tui.commentAdded, loadTaskComments]);
|
|
105
|
+
}, [i18n.tui.commentAdded, loadTaskComments, history]);
|
|
100
106
|
const deleteComment = useCallback(async (comment) => {
|
|
101
|
-
const
|
|
102
|
-
|
|
107
|
+
const command = new DeleteCommentCommand({
|
|
108
|
+
comment,
|
|
109
|
+
description: i18n.tui.commentDeleted || 'Comment deleted',
|
|
110
|
+
});
|
|
111
|
+
await history.execute(command);
|
|
103
112
|
setMessage(i18n.tui.commentDeleted || 'Comment deleted');
|
|
104
113
|
if (selectedTask) {
|
|
105
114
|
await loadTaskComments(selectedTask.id);
|
|
106
115
|
}
|
|
107
|
-
}, [i18n.tui.commentDeleted, loadTaskComments, selectedTask]);
|
|
116
|
+
}, [i18n.tui.commentDeleted, loadTaskComments, selectedTask, history]);
|
|
108
117
|
const linkTaskToProject = useCallback(async (task, project) => {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
.
|
|
118
|
+
const command = new LinkTaskCommand({
|
|
119
|
+
taskId: task.id,
|
|
120
|
+
fromParentId: task.parentId,
|
|
121
|
+
toParentId: project.id,
|
|
122
|
+
description: fmt(i18n.tui.linkedToProject || 'Linked "{title}" to {project}', { title: task.title, project: project.title }),
|
|
123
|
+
});
|
|
124
|
+
await history.execute(command);
|
|
113
125
|
setMessage(fmt(i18n.tui.linkedToProject || 'Linked "{title}" to {project}', { title: task.title, project: project.title }));
|
|
114
126
|
await loadTasks();
|
|
115
|
-
}, [i18n.tui.linkedToProject, loadTasks]);
|
|
127
|
+
}, [i18n.tui.linkedToProject, loadTasks, history]);
|
|
116
128
|
const currentColumn = COLUMNS[currentColumnIndex];
|
|
117
129
|
const currentTasks = tasks[currentColumn];
|
|
118
130
|
const selectedTaskIndex = selectedTaskIndices[currentColumn];
|
|
@@ -140,22 +152,49 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
|
|
|
140
152
|
setSearchResults(results);
|
|
141
153
|
setSearchResultIndex(0);
|
|
142
154
|
}, [searchTasks]);
|
|
155
|
+
// Navigate to a task from search results
|
|
156
|
+
const navigateToTask = useCallback((task) => {
|
|
157
|
+
// Determine which column the task belongs to based on status
|
|
158
|
+
let targetColumn;
|
|
159
|
+
if (task.status === 'inbox' || task.status === 'someday') {
|
|
160
|
+
targetColumn = 'todo';
|
|
161
|
+
}
|
|
162
|
+
else if (task.status === 'next' || task.status === 'waiting') {
|
|
163
|
+
targetColumn = 'doing';
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
targetColumn = 'done';
|
|
167
|
+
}
|
|
168
|
+
const columnIndex = COLUMNS.indexOf(targetColumn);
|
|
169
|
+
const taskIndex = tasks[targetColumn].findIndex(t => t.id === task.id);
|
|
170
|
+
if (columnIndex >= 0 && taskIndex >= 0) {
|
|
171
|
+
setCurrentColumnIndex(columnIndex);
|
|
172
|
+
setSelectedTaskIndices(prev => ({
|
|
173
|
+
...prev,
|
|
174
|
+
[targetColumn]: taskIndex,
|
|
175
|
+
}));
|
|
176
|
+
setMode('normal');
|
|
177
|
+
}
|
|
178
|
+
}, [tasks]);
|
|
143
179
|
const addTask = useCallback(async (title) => {
|
|
144
180
|
if (!title.trim())
|
|
145
181
|
return;
|
|
146
|
-
const db = getDb();
|
|
147
182
|
const now = new Date();
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
183
|
+
const taskId = uuidv4();
|
|
184
|
+
const command = new CreateTaskCommand({
|
|
185
|
+
task: {
|
|
186
|
+
id: taskId,
|
|
187
|
+
title: title.trim(),
|
|
188
|
+
status: 'inbox', // New tasks go to inbox (which maps to TODO)
|
|
189
|
+
createdAt: now,
|
|
190
|
+
updatedAt: now,
|
|
191
|
+
},
|
|
192
|
+
description: fmt(i18n.tui.added, { title: title.trim() }),
|
|
155
193
|
});
|
|
194
|
+
await history.execute(command);
|
|
156
195
|
setMessage(fmt(i18n.tui.added, { title: title.trim() }));
|
|
157
196
|
await loadTasks();
|
|
158
|
-
}, [i18n.tui.added, loadTasks]);
|
|
197
|
+
}, [i18n.tui.added, loadTasks, history]);
|
|
159
198
|
const handleInputSubmit = async (value) => {
|
|
160
199
|
if (mode === 'add-comment' && selectedTask) {
|
|
161
200
|
if (value.trim()) {
|
|
@@ -169,9 +208,7 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
|
|
|
169
208
|
if (mode === 'search') {
|
|
170
209
|
if (searchResults.length > 0) {
|
|
171
210
|
const task = searchResults[searchResultIndex];
|
|
172
|
-
|
|
173
|
-
loadTaskComments(task.id);
|
|
174
|
-
setMode('task-detail');
|
|
211
|
+
navigateToTask(task);
|
|
175
212
|
}
|
|
176
213
|
else {
|
|
177
214
|
setMode('normal');
|
|
@@ -188,7 +225,6 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
|
|
|
188
225
|
setMode('normal');
|
|
189
226
|
};
|
|
190
227
|
const moveTaskRight = useCallback(async (task) => {
|
|
191
|
-
const db = getDb();
|
|
192
228
|
let newStatus;
|
|
193
229
|
// Determine new status based on current status
|
|
194
230
|
if (task.status === 'inbox' || task.status === 'someday') {
|
|
@@ -203,14 +239,19 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
|
|
|
203
239
|
// Already done, do nothing
|
|
204
240
|
return;
|
|
205
241
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
242
|
+
const command = new MoveTaskCommand({
|
|
243
|
+
taskId: task.id,
|
|
244
|
+
fromStatus: task.status,
|
|
245
|
+
toStatus: newStatus,
|
|
246
|
+
fromWaitingFor: task.waitingFor,
|
|
247
|
+
toWaitingFor: null,
|
|
248
|
+
description: fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }),
|
|
249
|
+
});
|
|
250
|
+
await history.execute(command);
|
|
209
251
|
setMessage(fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }));
|
|
210
252
|
await loadTasks();
|
|
211
|
-
}, [i18n.tui.movedTo, i18n.status, loadTasks]);
|
|
253
|
+
}, [i18n.tui.movedTo, i18n.status, loadTasks, history]);
|
|
212
254
|
const moveTaskLeft = useCallback(async (task) => {
|
|
213
|
-
const db = getDb();
|
|
214
255
|
let newStatus;
|
|
215
256
|
// Determine new status based on current status
|
|
216
257
|
if (task.status === 'next' || task.status === 'waiting') {
|
|
@@ -225,20 +266,31 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
|
|
|
225
266
|
// Already in TODO, do nothing
|
|
226
267
|
return;
|
|
227
268
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
269
|
+
const command = new MoveTaskCommand({
|
|
270
|
+
taskId: task.id,
|
|
271
|
+
fromStatus: task.status,
|
|
272
|
+
toStatus: newStatus,
|
|
273
|
+
fromWaitingFor: task.waitingFor,
|
|
274
|
+
toWaitingFor: null,
|
|
275
|
+
description: fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }),
|
|
276
|
+
});
|
|
277
|
+
await history.execute(command);
|
|
231
278
|
setMessage(fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }));
|
|
232
279
|
await loadTasks();
|
|
233
|
-
}, [i18n.tui.movedTo, i18n.status, loadTasks]);
|
|
280
|
+
}, [i18n.tui.movedTo, i18n.status, loadTasks, history]);
|
|
234
281
|
const markTaskDone = useCallback(async (task) => {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
.
|
|
238
|
-
|
|
282
|
+
const command = new MoveTaskCommand({
|
|
283
|
+
taskId: task.id,
|
|
284
|
+
fromStatus: task.status,
|
|
285
|
+
toStatus: 'done',
|
|
286
|
+
fromWaitingFor: task.waitingFor,
|
|
287
|
+
toWaitingFor: null,
|
|
288
|
+
description: fmt(i18n.tui.completed, { title: task.title }),
|
|
289
|
+
});
|
|
290
|
+
await history.execute(command);
|
|
239
291
|
setMessage(fmt(i18n.tui.completed, { title: task.title }));
|
|
240
292
|
await loadTasks();
|
|
241
|
-
}, [i18n.tui.completed, loadTasks]);
|
|
293
|
+
}, [i18n.tui.completed, loadTasks, history]);
|
|
242
294
|
const getColumnLabel = (column) => {
|
|
243
295
|
return i18n.kanban[column];
|
|
244
296
|
};
|
|
@@ -257,12 +309,12 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
|
|
|
257
309
|
setMode('normal');
|
|
258
310
|
return;
|
|
259
311
|
}
|
|
260
|
-
// Navigate search results with Ctrl+j/k or Ctrl+n/p
|
|
261
|
-
if (key.ctrl && (input === 'j' || input === 'n')) {
|
|
312
|
+
// Navigate search results with arrow keys, Ctrl+j/k, or Ctrl+n/p
|
|
313
|
+
if (key.downArrow || (key.ctrl && (input === 'j' || input === 'n'))) {
|
|
262
314
|
setSearchResultIndex((prev) => prev < searchResults.length - 1 ? prev + 1 : 0);
|
|
263
315
|
return;
|
|
264
316
|
}
|
|
265
|
-
if (key.ctrl && (input === 'k' || input === 'p')) {
|
|
317
|
+
if (key.upArrow || (key.ctrl && (input === 'k' || input === 'p'))) {
|
|
266
318
|
setSearchResultIndex((prev) => prev > 0 ? prev - 1 : Math.max(0, searchResults.length - 1));
|
|
267
319
|
return;
|
|
268
320
|
}
|
|
@@ -478,11 +530,41 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
|
|
|
478
530
|
return;
|
|
479
531
|
}
|
|
480
532
|
// Refresh
|
|
481
|
-
if (input === 'r') {
|
|
533
|
+
if (input === 'r' && !(key.ctrl)) {
|
|
482
534
|
loadTasks();
|
|
483
535
|
setMessage(i18n.tui.refreshed);
|
|
484
536
|
return;
|
|
485
537
|
}
|
|
538
|
+
// Undo (u key) - only in normal mode
|
|
539
|
+
if (input === 'u' && mode === 'normal') {
|
|
540
|
+
history.undo().then((didUndo) => {
|
|
541
|
+
if (didUndo) {
|
|
542
|
+
setMessage(fmt(i18n.tui.undone, { action: history.undoDescription || '' }));
|
|
543
|
+
loadTasks();
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
setMessage(i18n.tui.nothingToUndo);
|
|
547
|
+
}
|
|
548
|
+
}).catch(() => {
|
|
549
|
+
setMessage(i18n.tui.undoFailed);
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// Redo (Ctrl+r) - only in normal mode
|
|
554
|
+
if (key.ctrl && input === 'r' && mode === 'normal') {
|
|
555
|
+
history.redo().then((didRedo) => {
|
|
556
|
+
if (didRedo) {
|
|
557
|
+
setMessage(fmt(i18n.tui.redone, { action: history.redoDescription || '' }));
|
|
558
|
+
loadTasks();
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
setMessage(i18n.tui.nothingToRedo);
|
|
562
|
+
}
|
|
563
|
+
}).catch(() => {
|
|
564
|
+
setMessage(i18n.tui.redoFailed);
|
|
565
|
+
});
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
486
568
|
});
|
|
487
569
|
// Help modal overlay
|
|
488
570
|
if (mode === 'help') {
|
|
@@ -12,7 +12,7 @@ export function SearchResults({ results, selectedIndex, query }) {
|
|
|
12
12
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.list, borderColor: theme.colors.borderActive, paddingX: 1, paddingY: 1, minHeight: 5, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: theme.colors.secondary, bold: true, children: ["[", search.resultsTitle, "] (", results.length, ")"] }) }), results.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: search.noResults })) : (results.slice(0, 10).map((task, index) => {
|
|
13
13
|
const isSelected = index === selectedIndex;
|
|
14
14
|
const shortId = task.id.slice(0, 8);
|
|
15
|
-
const
|
|
16
|
-
return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, _jsxs(Text, { color: theme.colors.textMuted, children: [" (",
|
|
15
|
+
const displayLabel = task.isProject ? i18n.tui.keyBar.project : i18n.status[task.status];
|
|
16
|
+
return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, _jsxs(Text, { color: theme.colors.textMuted, children: [" (", displayLabel, ")"] }), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" - ", task.waitingFor] }))] }) }, task.id));
|
|
17
17
|
})), results.length > 10 && (_jsxs(Text, { color: theme.colors.textMuted, italic: true, children: ["... and ", results.length - 10, " more"] }))] }));
|
|
18
18
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { UndoableCommand, HistoryState } from './types.js';
|
|
3
|
+
interface HistoryContextValue {
|
|
4
|
+
/** Execute a command and add to history */
|
|
5
|
+
execute: (command: UndoableCommand) => Promise<void>;
|
|
6
|
+
/** Undo the last command */
|
|
7
|
+
undo: () => Promise<boolean>;
|
|
8
|
+
/** Redo the last undone command */
|
|
9
|
+
redo: () => Promise<boolean>;
|
|
10
|
+
/** Check if undo is available */
|
|
11
|
+
canUndo: boolean;
|
|
12
|
+
/** Check if redo is available */
|
|
13
|
+
canRedo: boolean;
|
|
14
|
+
/** Get the current history state */
|
|
15
|
+
state: HistoryState;
|
|
16
|
+
/** Description of command that would be undone */
|
|
17
|
+
undoDescription: string | null;
|
|
18
|
+
/** Description of command that would be redone */
|
|
19
|
+
redoDescription: string | null;
|
|
20
|
+
}
|
|
21
|
+
interface HistoryProviderProps {
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
export declare function HistoryProvider({ children }: HistoryProviderProps): React.ReactElement;
|
|
25
|
+
/**
|
|
26
|
+
* Hook to access history context
|
|
27
|
+
*/
|
|
28
|
+
export declare function useHistoryContext(): HistoryContextValue;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react';
|
|
3
|
+
import { getHistoryManager } from './HistoryManager.js';
|
|
4
|
+
const HistoryContext = createContext(null);
|
|
5
|
+
export function HistoryProvider({ children }) {
|
|
6
|
+
const manager = useMemo(() => getHistoryManager(), []);
|
|
7
|
+
const [state, setState] = useState(manager.getState());
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const unsubscribe = manager.subscribe(() => {
|
|
10
|
+
setState(manager.getState());
|
|
11
|
+
});
|
|
12
|
+
return unsubscribe;
|
|
13
|
+
}, [manager]);
|
|
14
|
+
const execute = useCallback(async (command) => {
|
|
15
|
+
await manager.execute(command);
|
|
16
|
+
}, [manager]);
|
|
17
|
+
const undo = useCallback(async () => {
|
|
18
|
+
return manager.undo();
|
|
19
|
+
}, [manager]);
|
|
20
|
+
const redo = useCallback(async () => {
|
|
21
|
+
return manager.redo();
|
|
22
|
+
}, [manager]);
|
|
23
|
+
const value = useMemo(() => ({
|
|
24
|
+
execute,
|
|
25
|
+
undo,
|
|
26
|
+
redo,
|
|
27
|
+
canUndo: manager.canUndo(),
|
|
28
|
+
canRedo: manager.canRedo(),
|
|
29
|
+
state,
|
|
30
|
+
undoDescription: manager.getUndoDescription(),
|
|
31
|
+
redoDescription: manager.getRedoDescription(),
|
|
32
|
+
}), [execute, undo, redo, state, manager]);
|
|
33
|
+
return _jsx(HistoryContext.Provider, { value: value, children: children });
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Hook to access history context
|
|
37
|
+
*/
|
|
38
|
+
export function useHistoryContext() {
|
|
39
|
+
const context = useContext(HistoryContext);
|
|
40
|
+
if (!context) {
|
|
41
|
+
throw new Error('useHistoryContext must be used within a HistoryProvider');
|
|
42
|
+
}
|
|
43
|
+
return context;
|
|
44
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { UndoableCommand, HistoryState } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Manages undo/redo history using the Command Pattern
|
|
4
|
+
*/
|
|
5
|
+
export declare class HistoryManager {
|
|
6
|
+
private undoStack;
|
|
7
|
+
private redoStack;
|
|
8
|
+
private listeners;
|
|
9
|
+
/**
|
|
10
|
+
* Execute a command and add it to the undo stack
|
|
11
|
+
*/
|
|
12
|
+
execute(command: UndoableCommand): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Undo the last command
|
|
15
|
+
* @returns true if undo was performed, false if nothing to undo
|
|
16
|
+
*/
|
|
17
|
+
undo(): Promise<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* Redo the last undone command
|
|
20
|
+
* @returns true if redo was performed, false if nothing to redo
|
|
21
|
+
*/
|
|
22
|
+
redo(): Promise<boolean>;
|
|
23
|
+
/**
|
|
24
|
+
* Get the current history state
|
|
25
|
+
*/
|
|
26
|
+
getState(): HistoryState;
|
|
27
|
+
/**
|
|
28
|
+
* Check if undo is available
|
|
29
|
+
*/
|
|
30
|
+
canUndo(): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Check if redo is available
|
|
33
|
+
*/
|
|
34
|
+
canRedo(): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Get description of the command that would be undone
|
|
37
|
+
*/
|
|
38
|
+
getUndoDescription(): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Get description of the command that would be redone
|
|
41
|
+
*/
|
|
42
|
+
getRedoDescription(): string | null;
|
|
43
|
+
/**
|
|
44
|
+
* Clear all history
|
|
45
|
+
*/
|
|
46
|
+
clear(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Subscribe to history changes
|
|
49
|
+
*/
|
|
50
|
+
subscribe(listener: () => void): () => void;
|
|
51
|
+
private notifyListeners;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Get the singleton HistoryManager instance
|
|
55
|
+
*/
|
|
56
|
+
export declare function getHistoryManager(): HistoryManager;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { MAX_HISTORY_SIZE } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Manages undo/redo history using the Command Pattern
|
|
4
|
+
*/
|
|
5
|
+
export class HistoryManager {
|
|
6
|
+
undoStack = [];
|
|
7
|
+
redoStack = [];
|
|
8
|
+
listeners = new Set();
|
|
9
|
+
/**
|
|
10
|
+
* Execute a command and add it to the undo stack
|
|
11
|
+
*/
|
|
12
|
+
async execute(command) {
|
|
13
|
+
await command.execute();
|
|
14
|
+
// Add to undo stack
|
|
15
|
+
this.undoStack.push(command);
|
|
16
|
+
// Clear redo stack (new action invalidates redo history)
|
|
17
|
+
this.redoStack = [];
|
|
18
|
+
// Enforce max history size
|
|
19
|
+
if (this.undoStack.length > MAX_HISTORY_SIZE) {
|
|
20
|
+
this.undoStack.shift();
|
|
21
|
+
}
|
|
22
|
+
this.notifyListeners();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Undo the last command
|
|
26
|
+
* @returns true if undo was performed, false if nothing to undo
|
|
27
|
+
*/
|
|
28
|
+
async undo() {
|
|
29
|
+
const command = this.undoStack.pop();
|
|
30
|
+
if (!command) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
await command.undo();
|
|
35
|
+
this.redoStack.push(command);
|
|
36
|
+
this.notifyListeners();
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
// Re-add command to undo stack if undo fails
|
|
41
|
+
this.undoStack.push(command);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Redo the last undone command
|
|
47
|
+
* @returns true if redo was performed, false if nothing to redo
|
|
48
|
+
*/
|
|
49
|
+
async redo() {
|
|
50
|
+
const command = this.redoStack.pop();
|
|
51
|
+
if (!command) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
await command.execute();
|
|
56
|
+
this.undoStack.push(command);
|
|
57
|
+
this.notifyListeners();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
// Re-add command to redo stack if redo fails
|
|
62
|
+
this.redoStack.push(command);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get the current history state
|
|
68
|
+
*/
|
|
69
|
+
getState() {
|
|
70
|
+
return {
|
|
71
|
+
undoCount: this.undoStack.length,
|
|
72
|
+
redoCount: this.redoStack.length,
|
|
73
|
+
lastCommandDescription: this.undoStack.length > 0
|
|
74
|
+
? this.undoStack[this.undoStack.length - 1].description
|
|
75
|
+
: null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check if undo is available
|
|
80
|
+
*/
|
|
81
|
+
canUndo() {
|
|
82
|
+
return this.undoStack.length > 0;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Check if redo is available
|
|
86
|
+
*/
|
|
87
|
+
canRedo() {
|
|
88
|
+
return this.redoStack.length > 0;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get description of the command that would be undone
|
|
92
|
+
*/
|
|
93
|
+
getUndoDescription() {
|
|
94
|
+
return this.undoStack.length > 0
|
|
95
|
+
? this.undoStack[this.undoStack.length - 1].description
|
|
96
|
+
: null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Get description of the command that would be redone
|
|
100
|
+
*/
|
|
101
|
+
getRedoDescription() {
|
|
102
|
+
return this.redoStack.length > 0
|
|
103
|
+
? this.redoStack[this.redoStack.length - 1].description
|
|
104
|
+
: null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Clear all history
|
|
108
|
+
*/
|
|
109
|
+
clear() {
|
|
110
|
+
this.undoStack = [];
|
|
111
|
+
this.redoStack = [];
|
|
112
|
+
this.notifyListeners();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Subscribe to history changes
|
|
116
|
+
*/
|
|
117
|
+
subscribe(listener) {
|
|
118
|
+
this.listeners.add(listener);
|
|
119
|
+
return () => this.listeners.delete(listener);
|
|
120
|
+
}
|
|
121
|
+
notifyListeners() {
|
|
122
|
+
for (const listener of this.listeners) {
|
|
123
|
+
listener();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Singleton instance
|
|
128
|
+
let historyManagerInstance = null;
|
|
129
|
+
/**
|
|
130
|
+
* Get the singleton HistoryManager instance
|
|
131
|
+
*/
|
|
132
|
+
export function getHistoryManager() {
|
|
133
|
+
if (!historyManagerInstance) {
|
|
134
|
+
historyManagerInstance = new HistoryManager();
|
|
135
|
+
}
|
|
136
|
+
return historyManagerInstance;
|
|
137
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { UndoableCommand } from '../types.js';
|
|
2
|
+
import type { TaskStatus } from '../../../db/schema.js';
|
|
3
|
+
interface ConvertToProjectParams {
|
|
4
|
+
taskId: string;
|
|
5
|
+
originalStatus: TaskStatus;
|
|
6
|
+
description: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Command to convert a task to a project
|
|
10
|
+
*/
|
|
11
|
+
export declare class ConvertToProjectCommand implements UndoableCommand {
|
|
12
|
+
readonly description: string;
|
|
13
|
+
private readonly taskId;
|
|
14
|
+
private readonly originalStatus;
|
|
15
|
+
constructor(params: ConvertToProjectParams);
|
|
16
|
+
execute(): Promise<void>;
|
|
17
|
+
undo(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { getDb, schema } from '../../../db/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Command to convert a task to a project
|
|
5
|
+
*/
|
|
6
|
+
export class ConvertToProjectCommand {
|
|
7
|
+
description;
|
|
8
|
+
taskId;
|
|
9
|
+
originalStatus;
|
|
10
|
+
constructor(params) {
|
|
11
|
+
this.taskId = params.taskId;
|
|
12
|
+
this.originalStatus = params.originalStatus;
|
|
13
|
+
this.description = params.description;
|
|
14
|
+
}
|
|
15
|
+
async execute() {
|
|
16
|
+
const db = getDb();
|
|
17
|
+
await db
|
|
18
|
+
.update(schema.tasks)
|
|
19
|
+
.set({
|
|
20
|
+
isProject: true,
|
|
21
|
+
status: 'next',
|
|
22
|
+
updatedAt: new Date(),
|
|
23
|
+
})
|
|
24
|
+
.where(eq(schema.tasks.id, this.taskId));
|
|
25
|
+
}
|
|
26
|
+
async undo() {
|
|
27
|
+
const db = getDb();
|
|
28
|
+
await db
|
|
29
|
+
.update(schema.tasks)
|
|
30
|
+
.set({
|
|
31
|
+
isProject: false,
|
|
32
|
+
status: this.originalStatus,
|
|
33
|
+
updatedAt: new Date(),
|
|
34
|
+
})
|
|
35
|
+
.where(eq(schema.tasks.id, this.taskId));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { UndoableCommand } from '../types.js';
|
|
2
|
+
import type { NewComment } from '../../../db/schema.js';
|
|
3
|
+
interface CreateCommentParams {
|
|
4
|
+
comment: NewComment;
|
|
5
|
+
description: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Command to create a new comment
|
|
9
|
+
*/
|
|
10
|
+
export declare class CreateCommentCommand implements UndoableCommand {
|
|
11
|
+
readonly description: string;
|
|
12
|
+
private readonly comment;
|
|
13
|
+
private createdCommentId;
|
|
14
|
+
constructor(params: CreateCommentParams);
|
|
15
|
+
execute(): Promise<void>;
|
|
16
|
+
undo(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { getDb, schema } from '../../../db/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Command to create a new comment
|
|
5
|
+
*/
|
|
6
|
+
export class CreateCommentCommand {
|
|
7
|
+
description;
|
|
8
|
+
comment;
|
|
9
|
+
createdCommentId;
|
|
10
|
+
constructor(params) {
|
|
11
|
+
this.comment = params.comment;
|
|
12
|
+
this.description = params.description;
|
|
13
|
+
this.createdCommentId = params.comment.id;
|
|
14
|
+
}
|
|
15
|
+
async execute() {
|
|
16
|
+
const db = getDb();
|
|
17
|
+
await db.insert(schema.comments).values(this.comment);
|
|
18
|
+
}
|
|
19
|
+
async undo() {
|
|
20
|
+
const db = getDb();
|
|
21
|
+
await db.delete(schema.comments).where(eq(schema.comments.id, this.createdCommentId));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { UndoableCommand } from '../types.js';
|
|
2
|
+
import type { NewTask } from '../../../db/schema.js';
|
|
3
|
+
interface CreateTaskParams {
|
|
4
|
+
task: NewTask;
|
|
5
|
+
description: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Command to create a new task
|
|
9
|
+
*/
|
|
10
|
+
export declare class CreateTaskCommand implements UndoableCommand {
|
|
11
|
+
readonly description: string;
|
|
12
|
+
private readonly task;
|
|
13
|
+
private createdTaskId;
|
|
14
|
+
constructor(params: CreateTaskParams);
|
|
15
|
+
execute(): Promise<void>;
|
|
16
|
+
undo(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export {};
|