@evolve.labs/devflow 0.8.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/.claude/commands/agents/architect.md +1162 -0
- package/.claude/commands/agents/architect.meta.yaml +124 -0
- package/.claude/commands/agents/builder.md +1432 -0
- package/.claude/commands/agents/builder.meta.yaml +117 -0
- package/.claude/commands/agents/chronicler.md +633 -0
- package/.claude/commands/agents/chronicler.meta.yaml +217 -0
- package/.claude/commands/agents/guardian.md +456 -0
- package/.claude/commands/agents/guardian.meta.yaml +127 -0
- package/.claude/commands/agents/strategist.md +483 -0
- package/.claude/commands/agents/strategist.meta.yaml +158 -0
- package/.claude/commands/agents/system-designer.md +1137 -0
- package/.claude/commands/agents/system-designer.meta.yaml +156 -0
- package/.claude/commands/devflow-help.md +93 -0
- package/.claude/commands/devflow-status.md +60 -0
- package/.claude/commands/quick/create-adr.md +82 -0
- package/.claude/commands/quick/new-feature.md +57 -0
- package/.claude/commands/quick/security-check.md +54 -0
- package/.claude/commands/quick/system-design.md +58 -0
- package/.claude_project +52 -0
- package/.devflow/agents/architect.meta.yaml +122 -0
- package/.devflow/agents/builder.meta.yaml +116 -0
- package/.devflow/agents/chronicler.meta.yaml +222 -0
- package/.devflow/agents/guardian.meta.yaml +127 -0
- package/.devflow/agents/strategist.meta.yaml +158 -0
- package/.devflow/agents/system-designer.meta.yaml +265 -0
- package/.devflow/project.yaml +242 -0
- package/.gitignore-template +84 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/devflow.js +54 -0
- package/lib/autopilot.js +235 -0
- package/lib/autopilotConstants.js +213 -0
- package/lib/constants.js +95 -0
- package/lib/init.js +200 -0
- package/lib/update.js +181 -0
- package/lib/utils.js +157 -0
- package/lib/web.js +119 -0
- package/package.json +57 -0
- package/web/CHANGELOG.md +192 -0
- package/web/README.md +156 -0
- package/web/app/api/autopilot/execute/route.ts +102 -0
- package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
- package/web/app/api/files/route.ts +280 -0
- package/web/app/api/files/tree/route.ts +160 -0
- package/web/app/api/git/route.ts +201 -0
- package/web/app/api/health/route.ts +94 -0
- package/web/app/api/project/open/route.ts +134 -0
- package/web/app/api/search/route.ts +247 -0
- package/web/app/api/specs/route.ts +405 -0
- package/web/app/api/terminal/route.ts +222 -0
- package/web/app/globals.css +160 -0
- package/web/app/ide/layout.tsx +43 -0
- package/web/app/ide/page.tsx +216 -0
- package/web/app/layout.tsx +34 -0
- package/web/app/page.tsx +303 -0
- package/web/components/agents/AgentIcons.tsx +281 -0
- package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
- package/web/components/autopilot/AutopilotPanel.tsx +299 -0
- package/web/components/dashboard/DashboardPanel.tsx +393 -0
- package/web/components/editor/Breadcrumbs.tsx +134 -0
- package/web/components/editor/EditorPanel.tsx +120 -0
- package/web/components/editor/EditorTabs.tsx +229 -0
- package/web/components/editor/MarkdownPreview.tsx +154 -0
- package/web/components/editor/MermaidDiagram.tsx +113 -0
- package/web/components/editor/MonacoEditor.tsx +177 -0
- package/web/components/editor/TabContextMenu.tsx +207 -0
- package/web/components/git/GitPanel.tsx +534 -0
- package/web/components/layout/Shell.tsx +15 -0
- package/web/components/layout/StatusBar.tsx +100 -0
- package/web/components/modals/CommandPalette.tsx +393 -0
- package/web/components/modals/GlobalSearch.tsx +348 -0
- package/web/components/modals/QuickOpen.tsx +241 -0
- package/web/components/modals/RecentFiles.tsx +208 -0
- package/web/components/projects/ProjectSelector.tsx +147 -0
- package/web/components/settings/SettingItem.tsx +150 -0
- package/web/components/settings/SettingsPanel.tsx +323 -0
- package/web/components/specs/SpecsPanel.tsx +1091 -0
- package/web/components/terminal/TerminalPanel.tsx +683 -0
- package/web/components/ui/ContextMenu.tsx +182 -0
- package/web/components/ui/LoadingSpinner.tsx +66 -0
- package/web/components/ui/ResizeHandle.tsx +110 -0
- package/web/components/ui/Skeleton.tsx +108 -0
- package/web/components/ui/SkipLinks.tsx +37 -0
- package/web/components/ui/Toaster.tsx +57 -0
- package/web/hooks/useFocusTrap.ts +141 -0
- package/web/hooks/useKeyboardShortcuts.ts +169 -0
- package/web/hooks/useListNavigation.ts +237 -0
- package/web/lib/autopilotConstants.ts +213 -0
- package/web/lib/constants/agents.ts +67 -0
- package/web/lib/git.ts +339 -0
- package/web/lib/ptyManager.ts +191 -0
- package/web/lib/specsParser.ts +299 -0
- package/web/lib/stores/autopilotStore.ts +288 -0
- package/web/lib/stores/fileStore.ts +550 -0
- package/web/lib/stores/gitStore.ts +386 -0
- package/web/lib/stores/projectStore.ts +196 -0
- package/web/lib/stores/settingsStore.ts +126 -0
- package/web/lib/stores/specsStore.ts +297 -0
- package/web/lib/stores/uiStore.ts +175 -0
- package/web/lib/types/index.ts +177 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.js +50 -0
- package/web/package.json +54 -0
- package/web/postcss.config.js +6 -0
- package/web/tailwind.config.ts +68 -0
- package/web/tsconfig.json +41 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import { persist } from 'zustand/middleware';
|
|
3
|
+
import { toast } from 'sonner';
|
|
4
|
+
import type { FileNode, OpenFile } from '@/lib/types';
|
|
5
|
+
import { getExtension, getFileName, getLanguageFromExtension } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
const MAX_RECENT_FILES = 20;
|
|
8
|
+
const MAX_HISTORY_SIZE = 50;
|
|
9
|
+
|
|
10
|
+
interface FileState {
|
|
11
|
+
// State
|
|
12
|
+
tree: FileNode | null;
|
|
13
|
+
openFiles: OpenFile[];
|
|
14
|
+
activeFile: string | null;
|
|
15
|
+
expandedFolders: Set<string>;
|
|
16
|
+
isLoading: boolean;
|
|
17
|
+
isSaving: boolean;
|
|
18
|
+
savingFile: string | null;
|
|
19
|
+
scrollToLine: number | null;
|
|
20
|
+
|
|
21
|
+
// Navigation state (US-019)
|
|
22
|
+
pinnedFiles: string[];
|
|
23
|
+
tabHistory: string[];
|
|
24
|
+
historyIndex: number;
|
|
25
|
+
recentFiles: string[];
|
|
26
|
+
closedTabs: string[];
|
|
27
|
+
|
|
28
|
+
// Actions
|
|
29
|
+
loadTree: (projectPath: string) => Promise<void>;
|
|
30
|
+
openFile: (path: string) => Promise<void>;
|
|
31
|
+
closeFile: (path: string) => void;
|
|
32
|
+
setActiveFile: (path: string | null) => void;
|
|
33
|
+
updateFileContent: (path: string, content: string) => void;
|
|
34
|
+
saveFile: (path: string) => Promise<void>;
|
|
35
|
+
createFile: (path: string, type: 'file' | 'directory', content?: string) => Promise<void>;
|
|
36
|
+
deleteFile: (path: string) => Promise<void>;
|
|
37
|
+
renameFile: (oldPath: string, newPath: string) => Promise<void>;
|
|
38
|
+
toggleFolder: (path: string) => void;
|
|
39
|
+
setExpandedFolders: (paths: Set<string>) => void;
|
|
40
|
+
setScrollToLine: (line: number | null) => void;
|
|
41
|
+
|
|
42
|
+
// Navigation actions (US-019)
|
|
43
|
+
navigateBack: () => void;
|
|
44
|
+
navigateForward: () => void;
|
|
45
|
+
canGoBack: () => boolean;
|
|
46
|
+
canGoForward: () => boolean;
|
|
47
|
+
togglePinned: (path: string) => void;
|
|
48
|
+
isPinned: (path: string) => boolean;
|
|
49
|
+
getRecentFiles: () => string[];
|
|
50
|
+
closeOtherTabs: (exceptPath: string) => void;
|
|
51
|
+
closeTabsToRight: (path: string) => void;
|
|
52
|
+
closeAllTabs: () => void;
|
|
53
|
+
reopenClosedTab: () => void;
|
|
54
|
+
copyPath: (path: string) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const useFileStore = create<FileState>()(
|
|
58
|
+
persist(
|
|
59
|
+
(set, get) => ({
|
|
60
|
+
tree: null,
|
|
61
|
+
openFiles: [],
|
|
62
|
+
activeFile: null,
|
|
63
|
+
expandedFolders: new Set(),
|
|
64
|
+
isLoading: false,
|
|
65
|
+
isSaving: false,
|
|
66
|
+
savingFile: null,
|
|
67
|
+
scrollToLine: null,
|
|
68
|
+
|
|
69
|
+
// Navigation state (US-019)
|
|
70
|
+
pinnedFiles: [],
|
|
71
|
+
tabHistory: [],
|
|
72
|
+
historyIndex: -1,
|
|
73
|
+
recentFiles: [],
|
|
74
|
+
closedTabs: [],
|
|
75
|
+
|
|
76
|
+
loadTree: async (projectPath: string) => {
|
|
77
|
+
set({ isLoading: true });
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(
|
|
81
|
+
`/api/files/tree?path=${encodeURIComponent(projectPath)}`
|
|
82
|
+
);
|
|
83
|
+
const data = await response.json();
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(data.error || 'Failed to load file tree');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
set({ tree: data.root, isLoading: false });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error('Failed to load tree:', error);
|
|
92
|
+
set({ isLoading: false });
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
openFile: async (path: string) => {
|
|
97
|
+
const { openFiles, pinnedFiles, tabHistory, historyIndex, recentFiles } = get();
|
|
98
|
+
|
|
99
|
+
// Helper to add to history and recent
|
|
100
|
+
const addToNavigation = () => {
|
|
101
|
+
// Add to tab history (truncate if navigated back)
|
|
102
|
+
const newHistory = tabHistory.slice(0, historyIndex + 1);
|
|
103
|
+
newHistory.push(path);
|
|
104
|
+
if (newHistory.length > MAX_HISTORY_SIZE) {
|
|
105
|
+
newHistory.shift();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Add to recent files (no duplicates, max 20)
|
|
109
|
+
const newRecent = [path, ...recentFiles.filter((f) => f !== path)].slice(0, MAX_RECENT_FILES);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
tabHistory: newHistory,
|
|
113
|
+
historyIndex: newHistory.length - 1,
|
|
114
|
+
recentFiles: newRecent,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Check if already open
|
|
119
|
+
const existing = openFiles.find((f) => f.path === path);
|
|
120
|
+
if (existing) {
|
|
121
|
+
set((state) => ({
|
|
122
|
+
activeFile: path,
|
|
123
|
+
...addToNavigation(),
|
|
124
|
+
}));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(
|
|
130
|
+
`/api/files?path=${encodeURIComponent(path)}`
|
|
131
|
+
);
|
|
132
|
+
const data = await response.json();
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
throw new Error(data.error || 'Failed to read file');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ext = getExtension(path);
|
|
139
|
+
const newFile: OpenFile = {
|
|
140
|
+
path,
|
|
141
|
+
name: getFileName(path),
|
|
142
|
+
content: data.content,
|
|
143
|
+
originalContent: data.content,
|
|
144
|
+
isDirty: false,
|
|
145
|
+
language: getLanguageFromExtension(ext),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Sort files: pinned first, then by open order
|
|
149
|
+
const isPinned = pinnedFiles.includes(path);
|
|
150
|
+
|
|
151
|
+
set((state) => {
|
|
152
|
+
const newOpenFiles = [...state.openFiles, newFile];
|
|
153
|
+
// Sort: pinned first
|
|
154
|
+
newOpenFiles.sort((a, b) => {
|
|
155
|
+
const aPinned = state.pinnedFiles.includes(a.path);
|
|
156
|
+
const bPinned = state.pinnedFiles.includes(b.path);
|
|
157
|
+
if (aPinned && !bPinned) return -1;
|
|
158
|
+
if (!aPinned && bPinned) return 1;
|
|
159
|
+
return 0;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
openFiles: newOpenFiles,
|
|
164
|
+
activeFile: path,
|
|
165
|
+
...addToNavigation(),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error('Failed to open file:', error);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
closeFile: (path: string) => {
|
|
174
|
+
set((state) => {
|
|
175
|
+
const newOpenFiles = state.openFiles.filter((f) => f.path !== path);
|
|
176
|
+
let newActiveFile = state.activeFile;
|
|
177
|
+
|
|
178
|
+
// If closing active file, select another
|
|
179
|
+
if (state.activeFile === path) {
|
|
180
|
+
const index = state.openFiles.findIndex((f) => f.path === path);
|
|
181
|
+
if (newOpenFiles.length > 0) {
|
|
182
|
+
newActiveFile = newOpenFiles[Math.min(index, newOpenFiles.length - 1)].path;
|
|
183
|
+
} else {
|
|
184
|
+
newActiveFile = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Add to closed tabs for reopen (max 10)
|
|
189
|
+
const newClosedTabs = [path, ...state.closedTabs.filter((f) => f !== path)].slice(0, 10);
|
|
190
|
+
|
|
191
|
+
// Remove from pinned if closing
|
|
192
|
+
const newPinnedFiles = state.pinnedFiles.filter((f) => f !== path);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
openFiles: newOpenFiles,
|
|
196
|
+
activeFile: newActiveFile,
|
|
197
|
+
closedTabs: newClosedTabs,
|
|
198
|
+
pinnedFiles: newPinnedFiles,
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
setActiveFile: (path: string | null) => {
|
|
204
|
+
set({ activeFile: path });
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
updateFileContent: (path: string, content: string) => {
|
|
208
|
+
set((state) => ({
|
|
209
|
+
openFiles: state.openFiles.map((f) =>
|
|
210
|
+
f.path === path
|
|
211
|
+
? {
|
|
212
|
+
...f,
|
|
213
|
+
content,
|
|
214
|
+
isDirty: content !== f.originalContent,
|
|
215
|
+
}
|
|
216
|
+
: f
|
|
217
|
+
),
|
|
218
|
+
}));
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
saveFile: async (path: string) => {
|
|
222
|
+
const { openFiles } = get();
|
|
223
|
+
const file = openFiles.find((f) => f.path === path);
|
|
224
|
+
|
|
225
|
+
if (!file) return;
|
|
226
|
+
|
|
227
|
+
set({ isSaving: true, savingFile: path });
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const response = await fetch('/api/files', {
|
|
231
|
+
method: 'PUT',
|
|
232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
233
|
+
body: JSON.stringify({ path, content: file.content }),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (!response.ok) {
|
|
237
|
+
const data = await response.json();
|
|
238
|
+
throw new Error(data.error || 'Failed to save file');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
set((state) => ({
|
|
242
|
+
openFiles: state.openFiles.map((f) =>
|
|
243
|
+
f.path === path
|
|
244
|
+
? {
|
|
245
|
+
...f,
|
|
246
|
+
originalContent: f.content,
|
|
247
|
+
isDirty: false,
|
|
248
|
+
}
|
|
249
|
+
: f
|
|
250
|
+
),
|
|
251
|
+
isSaving: false,
|
|
252
|
+
savingFile: null,
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
toast.success('File saved', {
|
|
256
|
+
description: getFileName(path),
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
set({ isSaving: false, savingFile: null });
|
|
260
|
+
const message = error instanceof Error ? error.message : 'Failed to save file';
|
|
261
|
+
toast.error('Save failed', {
|
|
262
|
+
description: message,
|
|
263
|
+
});
|
|
264
|
+
console.error('Failed to save file:', error);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
createFile: async (path: string, type: 'file' | 'directory', content?: string) => {
|
|
269
|
+
try {
|
|
270
|
+
const response = await fetch('/api/files', {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: { 'Content-Type': 'application/json' },
|
|
273
|
+
body: JSON.stringify({ path, type, content }),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
const data = await response.json();
|
|
278
|
+
throw new Error(data.error || 'Failed to create file');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
toast.success(type === 'directory' ? 'Folder created' : 'File created', {
|
|
282
|
+
description: getFileName(path),
|
|
283
|
+
});
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const message = error instanceof Error ? error.message : 'Failed to create file';
|
|
286
|
+
toast.error('Create failed', {
|
|
287
|
+
description: message,
|
|
288
|
+
});
|
|
289
|
+
console.error('Failed to create file:', error);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
deleteFile: async (path: string) => {
|
|
294
|
+
try {
|
|
295
|
+
const response = await fetch('/api/files', {
|
|
296
|
+
method: 'DELETE',
|
|
297
|
+
headers: { 'Content-Type': 'application/json' },
|
|
298
|
+
body: JSON.stringify({ path }),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
const data = await response.json();
|
|
303
|
+
throw new Error(data.error || 'Failed to delete file');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Close if open
|
|
307
|
+
get().closeFile(path);
|
|
308
|
+
|
|
309
|
+
toast.success('Deleted', {
|
|
310
|
+
description: getFileName(path),
|
|
311
|
+
});
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const message = error instanceof Error ? error.message : 'Failed to delete file';
|
|
314
|
+
toast.error('Delete failed', {
|
|
315
|
+
description: message,
|
|
316
|
+
});
|
|
317
|
+
console.error('Failed to delete file:', error);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
renameFile: async (oldPath: string, newPath: string) => {
|
|
322
|
+
try {
|
|
323
|
+
const response = await fetch('/api/files', {
|
|
324
|
+
method: 'PATCH',
|
|
325
|
+
headers: { 'Content-Type': 'application/json' },
|
|
326
|
+
body: JSON.stringify({ oldPath, newPath }),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
const data = await response.json();
|
|
331
|
+
throw new Error(data.error || 'Failed to rename file');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Update open files if the renamed file was open
|
|
335
|
+
const { openFiles, activeFile } = get();
|
|
336
|
+
const renamedFile = openFiles.find((f) => f.path === oldPath);
|
|
337
|
+
|
|
338
|
+
if (renamedFile) {
|
|
339
|
+
const ext = getExtension(newPath);
|
|
340
|
+
set({
|
|
341
|
+
openFiles: openFiles.map((f) =>
|
|
342
|
+
f.path === oldPath
|
|
343
|
+
? {
|
|
344
|
+
...f,
|
|
345
|
+
path: newPath,
|
|
346
|
+
name: getFileName(newPath),
|
|
347
|
+
language: getLanguageFromExtension(ext),
|
|
348
|
+
}
|
|
349
|
+
: f
|
|
350
|
+
),
|
|
351
|
+
activeFile: activeFile === oldPath ? newPath : activeFile,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
toast.success('Renamed', {
|
|
356
|
+
description: `${getFileName(oldPath)} → ${getFileName(newPath)}`,
|
|
357
|
+
});
|
|
358
|
+
} catch (error) {
|
|
359
|
+
const message = error instanceof Error ? error.message : 'Failed to rename file';
|
|
360
|
+
toast.error('Rename failed', {
|
|
361
|
+
description: message,
|
|
362
|
+
});
|
|
363
|
+
console.error('Failed to rename file:', error);
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
toggleFolder: (path: string) => {
|
|
368
|
+
set((state) => {
|
|
369
|
+
const newExpanded = new Set(state.expandedFolders);
|
|
370
|
+
if (newExpanded.has(path)) {
|
|
371
|
+
newExpanded.delete(path);
|
|
372
|
+
} else {
|
|
373
|
+
newExpanded.add(path);
|
|
374
|
+
}
|
|
375
|
+
return { expandedFolders: newExpanded };
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
setExpandedFolders: (paths: Set<string>) => {
|
|
380
|
+
set({ expandedFolders: paths });
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
setScrollToLine: (line: number | null) => {
|
|
384
|
+
set({ scrollToLine: line });
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
// Navigation actions (US-019)
|
|
388
|
+
navigateBack: () => {
|
|
389
|
+
const { tabHistory, historyIndex, openFile } = get();
|
|
390
|
+
if (historyIndex > 0) {
|
|
391
|
+
const newIndex = historyIndex - 1;
|
|
392
|
+
const path = tabHistory[newIndex];
|
|
393
|
+
set({ historyIndex: newIndex, activeFile: path });
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
navigateForward: () => {
|
|
398
|
+
const { tabHistory, historyIndex } = get();
|
|
399
|
+
if (historyIndex < tabHistory.length - 1) {
|
|
400
|
+
const newIndex = historyIndex + 1;
|
|
401
|
+
const path = tabHistory[newIndex];
|
|
402
|
+
set({ historyIndex: newIndex, activeFile: path });
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
canGoBack: () => {
|
|
407
|
+
const { historyIndex } = get();
|
|
408
|
+
return historyIndex > 0;
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
canGoForward: () => {
|
|
412
|
+
const { tabHistory, historyIndex } = get();
|
|
413
|
+
return historyIndex < tabHistory.length - 1;
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
togglePinned: (path: string) => {
|
|
417
|
+
set((state) => {
|
|
418
|
+
const isPinned = state.pinnedFiles.includes(path);
|
|
419
|
+
const newPinnedFiles = isPinned
|
|
420
|
+
? state.pinnedFiles.filter((f) => f !== path)
|
|
421
|
+
: [...state.pinnedFiles, path];
|
|
422
|
+
|
|
423
|
+
// Re-sort open files: pinned first
|
|
424
|
+
const newOpenFiles = [...state.openFiles].sort((a, b) => {
|
|
425
|
+
const aPinned = newPinnedFiles.includes(a.path);
|
|
426
|
+
const bPinned = newPinnedFiles.includes(b.path);
|
|
427
|
+
if (aPinned && !bPinned) return -1;
|
|
428
|
+
if (!aPinned && bPinned) return 1;
|
|
429
|
+
return 0;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
toast.success(isPinned ? 'Tab unpinned' : 'Tab pinned', {
|
|
433
|
+
description: getFileName(path),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
pinnedFiles: newPinnedFiles,
|
|
438
|
+
openFiles: newOpenFiles,
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
isPinned: (path: string) => {
|
|
444
|
+
return get().pinnedFiles.includes(path);
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
getRecentFiles: () => {
|
|
448
|
+
return get().recentFiles;
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
closeOtherTabs: (exceptPath: string) => {
|
|
452
|
+
const { openFiles, pinnedFiles, closeFile } = get();
|
|
453
|
+
|
|
454
|
+
// Close all non-pinned tabs except the specified one
|
|
455
|
+
const tabsToClose = openFiles
|
|
456
|
+
.filter((f) => f.path !== exceptPath && !pinnedFiles.includes(f.path))
|
|
457
|
+
.map((f) => f.path);
|
|
458
|
+
|
|
459
|
+
tabsToClose.forEach((path) => {
|
|
460
|
+
get().closeFile(path);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
toast.success('Closed other tabs', {
|
|
464
|
+
description: `${tabsToClose.length} tabs closed`,
|
|
465
|
+
});
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
closeTabsToRight: (path: string) => {
|
|
469
|
+
const { openFiles, pinnedFiles } = get();
|
|
470
|
+
const index = openFiles.findIndex((f) => f.path === path);
|
|
471
|
+
|
|
472
|
+
if (index === -1) return;
|
|
473
|
+
|
|
474
|
+
// Close all tabs to the right that are not pinned
|
|
475
|
+
const tabsToClose = openFiles
|
|
476
|
+
.slice(index + 1)
|
|
477
|
+
.filter((f) => !pinnedFiles.includes(f.path))
|
|
478
|
+
.map((f) => f.path);
|
|
479
|
+
|
|
480
|
+
tabsToClose.forEach((tabPath) => {
|
|
481
|
+
get().closeFile(tabPath);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
if (tabsToClose.length > 0) {
|
|
485
|
+
toast.success('Closed tabs to right', {
|
|
486
|
+
description: `${tabsToClose.length} tabs closed`,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
closeAllTabs: () => {
|
|
492
|
+
const { openFiles, pinnedFiles } = get();
|
|
493
|
+
|
|
494
|
+
// Close all non-pinned tabs
|
|
495
|
+
const tabsToClose = openFiles
|
|
496
|
+
.filter((f) => !pinnedFiles.includes(f.path))
|
|
497
|
+
.map((f) => f.path);
|
|
498
|
+
|
|
499
|
+
tabsToClose.forEach((path) => {
|
|
500
|
+
get().closeFile(path);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
toast.success('Closed all tabs', {
|
|
504
|
+
description: `${tabsToClose.length} tabs closed`,
|
|
505
|
+
});
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
reopenClosedTab: () => {
|
|
509
|
+
const { closedTabs, openFile } = get();
|
|
510
|
+
|
|
511
|
+
if (closedTabs.length === 0) {
|
|
512
|
+
toast.info('No closed tabs to reopen');
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const pathToReopen = closedTabs[0];
|
|
517
|
+
|
|
518
|
+
// Remove from closed tabs
|
|
519
|
+
set((state) => ({
|
|
520
|
+
closedTabs: state.closedTabs.slice(1),
|
|
521
|
+
}));
|
|
522
|
+
|
|
523
|
+
// Open the file
|
|
524
|
+
openFile(pathToReopen);
|
|
525
|
+
|
|
526
|
+
toast.success('Tab reopened', {
|
|
527
|
+
description: getFileName(pathToReopen),
|
|
528
|
+
});
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
copyPath: (path: string) => {
|
|
532
|
+
navigator.clipboard.writeText(path).then(() => {
|
|
533
|
+
toast.success('Path copied', {
|
|
534
|
+
description: path,
|
|
535
|
+
});
|
|
536
|
+
}).catch(() => {
|
|
537
|
+
toast.error('Failed to copy path');
|
|
538
|
+
});
|
|
539
|
+
},
|
|
540
|
+
}),
|
|
541
|
+
{
|
|
542
|
+
name: 'devflow-file-store',
|
|
543
|
+
partialize: (state) => ({
|
|
544
|
+
pinnedFiles: state.pinnedFiles,
|
|
545
|
+
recentFiles: state.recentFiles,
|
|
546
|
+
closedTabs: state.closedTabs,
|
|
547
|
+
}),
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
);
|