@vibe80/vibe80 0.1.1
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/LICENSE +201 -0
- package/README.md +52 -0
- package/bin/vibe80.js +176 -0
- package/client/dist/assets/DiffPanel-C_IGzKI5.js +1 -0
- package/client/dist/assets/ExplorerPanel-BtlyAT00.js +11 -0
- package/client/dist/assets/LogsPanel-BW79JWzR.js +1 -0
- package/client/dist/assets/SettingsPanel-b9B7ygP_.js +1 -0
- package/client/dist/assets/TerminalPanel-C3fc1HbK.js +1 -0
- package/client/dist/assets/browser-e3WgtMs-.js +8 -0
- package/client/dist/assets/index-CgqGyssr.css +32 -0
- package/client/dist/assets/index-DnwKjoj7.js +706 -0
- package/client/dist/assets/vibe80_dark-D7OVPKcU.svg +51 -0
- package/client/dist/assets/vibe80_light-BJK37ybI.svg +50 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/favicon.png +0 -0
- package/client/dist/favicon.svg +35 -0
- package/client/dist/index.html +14 -0
- package/client/index.html +16 -0
- package/client/package.json +34 -0
- package/client/public/favicon.ico +0 -0
- package/client/public/favicon.png +0 -0
- package/client/public/favicon.svg +35 -0
- package/client/public/pwa-192x192.png +0 -0
- package/client/public/pwa-512x512.png +0 -0
- package/client/src/App.jsx +3131 -0
- package/client/src/assets/logo_small.png +0 -0
- package/client/src/assets/vibe80_dark.svg +51 -0
- package/client/src/assets/vibe80_light.svg +50 -0
- package/client/src/components/Chat/ChatComposer.jsx +228 -0
- package/client/src/components/Chat/ChatMessages.jsx +811 -0
- package/client/src/components/Chat/ChatToolbar.jsx +109 -0
- package/client/src/components/Chat/useChatComposer.js +462 -0
- package/client/src/components/Diff/DiffPanel.jsx +129 -0
- package/client/src/components/Explorer/ExplorerPanel.jsx +449 -0
- package/client/src/components/Logs/LogsPanel.jsx +80 -0
- package/client/src/components/SessionGate/SessionGate.jsx +874 -0
- package/client/src/components/Settings/SettingsPanel.jsx +212 -0
- package/client/src/components/Terminal/TerminalPanel.jsx +39 -0
- package/client/src/components/Topbar/Topbar.jsx +101 -0
- package/client/src/components/WorktreeTabs.css +419 -0
- package/client/src/components/WorktreeTabs.jsx +604 -0
- package/client/src/hooks/useAttachments.jsx +125 -0
- package/client/src/hooks/useBacklog.js +254 -0
- package/client/src/hooks/useChatClear.js +90 -0
- package/client/src/hooks/useChatCollapse.js +42 -0
- package/client/src/hooks/useChatCommands.js +294 -0
- package/client/src/hooks/useChatExport.js +144 -0
- package/client/src/hooks/useChatMessagesState.js +69 -0
- package/client/src/hooks/useChatSend.js +158 -0
- package/client/src/hooks/useChatSocket.js +1239 -0
- package/client/src/hooks/useDiffNavigation.js +19 -0
- package/client/src/hooks/useExplorerActions.js +1184 -0
- package/client/src/hooks/useGitIdentity.js +114 -0
- package/client/src/hooks/useLayoutMode.js +31 -0
- package/client/src/hooks/useLocalPreferences.js +131 -0
- package/client/src/hooks/useMessageSync.js +30 -0
- package/client/src/hooks/useNotifications.js +132 -0
- package/client/src/hooks/usePaneNavigation.js +67 -0
- package/client/src/hooks/usePanelState.js +13 -0
- package/client/src/hooks/useProviderSelection.js +70 -0
- package/client/src/hooks/useRepoBranchesModels.js +218 -0
- package/client/src/hooks/useRepoStatus.js +350 -0
- package/client/src/hooks/useRpcLogActions.js +19 -0
- package/client/src/hooks/useRpcLogView.js +58 -0
- package/client/src/hooks/useSessionHandoff.js +97 -0
- package/client/src/hooks/useSessionLifecycle.js +287 -0
- package/client/src/hooks/useSessionReset.js +63 -0
- package/client/src/hooks/useSessionResync.js +77 -0
- package/client/src/hooks/useTerminalSession.js +328 -0
- package/client/src/hooks/useToolbarExport.js +27 -0
- package/client/src/hooks/useTurnInterrupt.js +43 -0
- package/client/src/hooks/useVibe80Forms.js +128 -0
- package/client/src/hooks/useWorkspaceAuth.js +932 -0
- package/client/src/hooks/useWorktreeCloseConfirm.js +46 -0
- package/client/src/hooks/useWorktrees.js +396 -0
- package/client/src/i18n.jsx +87 -0
- package/client/src/index.css +5147 -0
- package/client/src/locales/en.json +37 -0
- package/client/src/locales/fr.json +321 -0
- package/client/src/main.jsx +16 -0
- package/client/vite.config.js +62 -0
- package/docs/api/asyncapi.json +1511 -0
- package/docs/api/openapi.json +3242 -0
- package/git_hooks/prepare-commit-msg +35 -0
- package/package.json +36 -0
- package/server/package.json +29 -0
- package/server/scripts/rotate-workspace-secret.js +101 -0
- package/server/src/claudeClient.js +454 -0
- package/server/src/clientEvents.js +594 -0
- package/server/src/clientFactory.js +164 -0
- package/server/src/codexClient.js +468 -0
- package/server/src/config.js +27 -0
- package/server/src/helpers.js +138 -0
- package/server/src/index.js +1641 -0
- package/server/src/middleware/auth.js +93 -0
- package/server/src/middleware/debug.js +89 -0
- package/server/src/middleware/errorTypes.js +60 -0
- package/server/src/providerLogger.js +60 -0
- package/server/src/routes/files.js +114 -0
- package/server/src/routes/git.js +183 -0
- package/server/src/routes/health.js +13 -0
- package/server/src/routes/sessions.js +407 -0
- package/server/src/routes/workspaces.js +296 -0
- package/server/src/routes/worktrees.js +993 -0
- package/server/src/runAs.js +458 -0
- package/server/src/runtimeStore.js +32 -0
- package/server/src/services/auth.js +157 -0
- package/server/src/services/claudeThreadDirectory.js +33 -0
- package/server/src/services/session.js +918 -0
- package/server/src/services/workspace.js +858 -0
- package/server/src/storage/index.js +17 -0
- package/server/src/storage/redis.js +412 -0
- package/server/src/storage/sqlite.js +649 -0
- package/server/src/worktreeManager.js +717 -0
- package/server/tests/README.md +13 -0
- package/server/tests/factories/workspaceFactory.js +13 -0
- package/server/tests/fixtures/workspaceCredentials.json +4 -0
- package/server/tests/integration/routes/workspaces-routes.test.js +626 -0
- package/server/tests/setup/env.js +9 -0
- package/server/tests/unit/helpers.test.js +95 -0
- package/server/tests/unit/services/auth.test.js +181 -0
- package/server/tests/unit/services/workspace.test.js +115 -0
- package/server/vitest.config.js +23 -0
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
const pathBasename = (value) => {
|
|
4
|
+
const normalized = String(value || "").replace(/\\/g, "/");
|
|
5
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
6
|
+
return parts.length ? parts[parts.length - 1] : "";
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const pathDirname = (value) => {
|
|
10
|
+
const normalized = String(value || "").replace(/\\/g, "/");
|
|
11
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
12
|
+
if (parts.length <= 1) {
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
return parts.slice(0, -1).join("/");
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const joinPath = (baseDir, name) => {
|
|
19
|
+
if (!baseDir) {
|
|
20
|
+
return name;
|
|
21
|
+
}
|
|
22
|
+
return `${baseDir}/${name}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const remapPath = (value, fromPath, toPath) => {
|
|
26
|
+
if (!value || typeof value !== "string") {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
if (value === fromPath) {
|
|
30
|
+
return toPath;
|
|
31
|
+
}
|
|
32
|
+
if (value.startsWith(`${fromPath}/`)) {
|
|
33
|
+
return `${toPath}${value.slice(fromPath.length)}`;
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default function useExplorerActions({
|
|
39
|
+
attachmentSessionId,
|
|
40
|
+
apiFetch,
|
|
41
|
+
t,
|
|
42
|
+
setExplorerByTab,
|
|
43
|
+
explorerDefaultState,
|
|
44
|
+
explorerRef,
|
|
45
|
+
activeWorktreeId,
|
|
46
|
+
handleViewSelect,
|
|
47
|
+
showToast,
|
|
48
|
+
requestExplorerTreeRef,
|
|
49
|
+
requestExplorerStatusRef,
|
|
50
|
+
loadExplorerFileRef,
|
|
51
|
+
}) {
|
|
52
|
+
const updateExplorerState = useCallback(
|
|
53
|
+
(tabId, patch) => {
|
|
54
|
+
setExplorerByTab((current) => {
|
|
55
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
56
|
+
return {
|
|
57
|
+
...current,
|
|
58
|
+
[tabId]: {
|
|
59
|
+
...explorerDefaultState,
|
|
60
|
+
...prev,
|
|
61
|
+
...patch,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
[explorerDefaultState, setExplorerByTab]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const findExplorerNode = useCallback((nodes, targetPath) => {
|
|
70
|
+
if (!Array.isArray(nodes)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
for (const node of nodes) {
|
|
74
|
+
if (node?.path === targetPath) {
|
|
75
|
+
return node;
|
|
76
|
+
}
|
|
77
|
+
if (node?.type === "dir" && Array.isArray(node.children)) {
|
|
78
|
+
const match = findExplorerNode(node.children, targetPath);
|
|
79
|
+
if (match) {
|
|
80
|
+
return match;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const updateExplorerTreeNodes = useCallback((nodes, targetPath, children) => {
|
|
88
|
+
if (!Array.isArray(nodes)) {
|
|
89
|
+
return nodes;
|
|
90
|
+
}
|
|
91
|
+
let changed = false;
|
|
92
|
+
const next = nodes.map((node) => {
|
|
93
|
+
if (!node) {
|
|
94
|
+
return node;
|
|
95
|
+
}
|
|
96
|
+
if (node.path === targetPath) {
|
|
97
|
+
changed = true;
|
|
98
|
+
return {
|
|
99
|
+
...node,
|
|
100
|
+
children,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (node.type === "dir" && node.children != null) {
|
|
104
|
+
const updatedChildren = updateExplorerTreeNodes(
|
|
105
|
+
node.children,
|
|
106
|
+
targetPath,
|
|
107
|
+
children
|
|
108
|
+
);
|
|
109
|
+
if (updatedChildren !== node.children) {
|
|
110
|
+
changed = true;
|
|
111
|
+
return {
|
|
112
|
+
...node,
|
|
113
|
+
children: updatedChildren,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return node;
|
|
118
|
+
});
|
|
119
|
+
return changed ? next : nodes;
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const setExplorerNodeChildren = useCallback(
|
|
123
|
+
(tabId, targetPath, children) => {
|
|
124
|
+
setExplorerByTab((current) => {
|
|
125
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
126
|
+
const tree = Array.isArray(prev.tree) ? prev.tree : [];
|
|
127
|
+
const nextTree = updateExplorerTreeNodes(tree, targetPath, children);
|
|
128
|
+
if (nextTree === tree) {
|
|
129
|
+
return current;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
...current,
|
|
133
|
+
[tabId]: {
|
|
134
|
+
...explorerDefaultState,
|
|
135
|
+
...prev,
|
|
136
|
+
tree: nextTree,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
[explorerDefaultState, setExplorerByTab, updateExplorerTreeNodes]
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const fetchExplorerChildren = useCallback(
|
|
145
|
+
async (tabId, dirPath) => {
|
|
146
|
+
if (!attachmentSessionId || !tabId) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const response = await apiFetch(
|
|
150
|
+
`/api/v1/sessions/${encodeURIComponent(
|
|
151
|
+
attachmentSessionId
|
|
152
|
+
)}/worktrees/${encodeURIComponent(tabId)}/browse${dirPath ? `?path=${encodeURIComponent(dirPath)}` : ""}`
|
|
153
|
+
);
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
throw new Error("Failed to load directory");
|
|
156
|
+
}
|
|
157
|
+
const payload = await response.json().catch(() => ({}));
|
|
158
|
+
const entries = Array.isArray(payload?.entries) ? payload.entries : [];
|
|
159
|
+
const normalized = entries.map((entry) => ({
|
|
160
|
+
...entry,
|
|
161
|
+
children: entry?.type === "dir" ? entry?.children ?? null : undefined,
|
|
162
|
+
}));
|
|
163
|
+
if (!dirPath) {
|
|
164
|
+
updateExplorerState(tabId, {
|
|
165
|
+
tree: normalized,
|
|
166
|
+
loading: false,
|
|
167
|
+
error: "",
|
|
168
|
+
treeTruncated: false,
|
|
169
|
+
treeTotal: normalized.length,
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
setExplorerNodeChildren(tabId, dirPath, normalized);
|
|
173
|
+
}
|
|
174
|
+
return normalized;
|
|
175
|
+
},
|
|
176
|
+
[
|
|
177
|
+
attachmentSessionId,
|
|
178
|
+
apiFetch,
|
|
179
|
+
setExplorerNodeChildren,
|
|
180
|
+
updateExplorerState,
|
|
181
|
+
]
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const normalizeOpenPath = useCallback((rawPath) => {
|
|
185
|
+
if (!rawPath) {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
return rawPath
|
|
189
|
+
.trim()
|
|
190
|
+
.replace(/\\/g, "/")
|
|
191
|
+
.replace(/^\.\/+/, "")
|
|
192
|
+
.replace(/\/+$/, "")
|
|
193
|
+
.replace(/\/+/g, "/");
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
const expandExplorerDir = useCallback(
|
|
197
|
+
(tabId, dirPath) => {
|
|
198
|
+
if (!dirPath) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const parts = dirPath.split("/").filter(Boolean);
|
|
202
|
+
const expanded = [];
|
|
203
|
+
let current = "";
|
|
204
|
+
parts.forEach((part) => {
|
|
205
|
+
current = current ? `${current}/${part}` : part;
|
|
206
|
+
expanded.push(current);
|
|
207
|
+
});
|
|
208
|
+
updateExplorerState(tabId, {
|
|
209
|
+
expandedPaths: expanded,
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
[updateExplorerState]
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const selectExplorerNode = useCallback(
|
|
216
|
+
(tabId, nodePath, nodeType) => {
|
|
217
|
+
if (!tabId || !nodePath) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
updateExplorerState(tabId, {
|
|
221
|
+
selectedPath: nodePath,
|
|
222
|
+
selectedType: nodeType || null,
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
[updateExplorerState]
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const requestExplorerTree = useCallback(
|
|
229
|
+
async (tabId, force = false) => {
|
|
230
|
+
if (!attachmentSessionId || !tabId) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const existing = explorerRef.current[tabId];
|
|
234
|
+
if (!force && existing?.tree && !existing?.error) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (existing?.loading) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
updateExplorerState(tabId, { loading: true, error: "" });
|
|
241
|
+
try {
|
|
242
|
+
await fetchExplorerChildren(tabId, "");
|
|
243
|
+
if (force) {
|
|
244
|
+
const expandedPaths = Array.isArray(explorerRef.current[tabId]?.expandedPaths)
|
|
245
|
+
? explorerRef.current[tabId].expandedPaths
|
|
246
|
+
: [];
|
|
247
|
+
const uniqueExpanded = Array.from(
|
|
248
|
+
new Set(
|
|
249
|
+
expandedPaths.filter(
|
|
250
|
+
(path) => typeof path === "string" && path.length > 0
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
);
|
|
254
|
+
for (const dirPath of uniqueExpanded) {
|
|
255
|
+
await fetchExplorerChildren(tabId, dirPath);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
updateExplorerState(tabId, {
|
|
260
|
+
loading: false,
|
|
261
|
+
error: t("Unable to load the explorer."),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
[attachmentSessionId, explorerRef, updateExplorerState, fetchExplorerChildren, t]
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const requestExplorerStatus = useCallback(
|
|
269
|
+
async (tabId, force = false) => {
|
|
270
|
+
if (!attachmentSessionId || !tabId) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const existing = explorerRef.current[tabId];
|
|
274
|
+
if (!force && existing?.statusLoaded && !existing?.statusError) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (existing?.statusLoading) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
updateExplorerState(tabId, { statusLoading: true, statusError: "" });
|
|
281
|
+
try {
|
|
282
|
+
const response = await apiFetch(
|
|
283
|
+
`/api/v1/sessions/${encodeURIComponent(
|
|
284
|
+
attachmentSessionId
|
|
285
|
+
)}/worktrees/${encodeURIComponent(tabId)}/status`
|
|
286
|
+
);
|
|
287
|
+
if (!response.ok) {
|
|
288
|
+
throw new Error("Failed to load status");
|
|
289
|
+
}
|
|
290
|
+
const payload = await response.json();
|
|
291
|
+
const entries = Array.isArray(payload?.entries) ? payload.entries : [];
|
|
292
|
+
const statusByPath = {};
|
|
293
|
+
entries.forEach((entry) => {
|
|
294
|
+
if (!entry?.path || !entry?.type) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
statusByPath[entry.path] = entry.type;
|
|
298
|
+
});
|
|
299
|
+
updateExplorerState(tabId, {
|
|
300
|
+
statusByPath,
|
|
301
|
+
statusLoading: false,
|
|
302
|
+
statusError: "",
|
|
303
|
+
statusLoaded: true,
|
|
304
|
+
});
|
|
305
|
+
} catch {
|
|
306
|
+
updateExplorerState(tabId, {
|
|
307
|
+
statusLoading: false,
|
|
308
|
+
statusError: t("Unable to load Git status."),
|
|
309
|
+
statusLoaded: false,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
[attachmentSessionId, explorerRef, updateExplorerState, apiFetch, t]
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const loadExplorerFile = useCallback(
|
|
317
|
+
async (tabId, filePath, force = false) => {
|
|
318
|
+
if (!attachmentSessionId || !tabId || !filePath) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const existingState = explorerRef.current[tabId] || explorerDefaultState;
|
|
322
|
+
const openTabPaths = Array.isArray(existingState.openTabPaths)
|
|
323
|
+
? existingState.openTabPaths
|
|
324
|
+
: [];
|
|
325
|
+
const existingFile = existingState.filesByPath?.[filePath];
|
|
326
|
+
|
|
327
|
+
setExplorerByTab((current) => {
|
|
328
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
329
|
+
const prevOpenTabs = Array.isArray(prev.openTabPaths)
|
|
330
|
+
? prev.openTabPaths
|
|
331
|
+
: [];
|
|
332
|
+
const nextOpenTabs = prevOpenTabs.includes(filePath)
|
|
333
|
+
? prevOpenTabs
|
|
334
|
+
: [...prevOpenTabs, filePath];
|
|
335
|
+
const prevFile = prev.filesByPath?.[filePath] || {};
|
|
336
|
+
const shouldLoad = force || prevFile.content == null;
|
|
337
|
+
return {
|
|
338
|
+
...current,
|
|
339
|
+
[tabId]: {
|
|
340
|
+
...explorerDefaultState,
|
|
341
|
+
...prev,
|
|
342
|
+
openTabPaths: nextOpenTabs,
|
|
343
|
+
activeFilePath: filePath,
|
|
344
|
+
selectedPath: filePath,
|
|
345
|
+
selectedType: "file",
|
|
346
|
+
editMode: true,
|
|
347
|
+
filesByPath: {
|
|
348
|
+
...(prev.filesByPath || {}),
|
|
349
|
+
[filePath]: {
|
|
350
|
+
...prevFile,
|
|
351
|
+
path: filePath,
|
|
352
|
+
loading: shouldLoad,
|
|
353
|
+
error: "",
|
|
354
|
+
saveError: "",
|
|
355
|
+
saving: false,
|
|
356
|
+
binary: shouldLoad ? false : Boolean(prevFile.binary),
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (!force && openTabPaths.includes(filePath) && existingFile?.content != null) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
const response = await apiFetch(
|
|
369
|
+
`/api/v1/sessions/${encodeURIComponent(
|
|
370
|
+
attachmentSessionId
|
|
371
|
+
)}/worktrees/${encodeURIComponent(
|
|
372
|
+
tabId
|
|
373
|
+
)}/file?path=${encodeURIComponent(filePath)}`
|
|
374
|
+
);
|
|
375
|
+
if (!response.ok) {
|
|
376
|
+
throw new Error("Failed to load file");
|
|
377
|
+
}
|
|
378
|
+
const payload = await response.json();
|
|
379
|
+
const content = payload?.content || "";
|
|
380
|
+
setExplorerByTab((current) => {
|
|
381
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
382
|
+
const prevFile = prev.filesByPath?.[filePath] || {};
|
|
383
|
+
return {
|
|
384
|
+
...current,
|
|
385
|
+
[tabId]: {
|
|
386
|
+
...explorerDefaultState,
|
|
387
|
+
...prev,
|
|
388
|
+
filesByPath: {
|
|
389
|
+
...(prev.filesByPath || {}),
|
|
390
|
+
[filePath]: {
|
|
391
|
+
...prevFile,
|
|
392
|
+
path: filePath,
|
|
393
|
+
content,
|
|
394
|
+
draftContent: content,
|
|
395
|
+
loading: false,
|
|
396
|
+
error: "",
|
|
397
|
+
truncated: Boolean(payload?.truncated),
|
|
398
|
+
binary: Boolean(payload?.binary),
|
|
399
|
+
isDirty: false,
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
} catch {
|
|
406
|
+
setExplorerByTab((current) => {
|
|
407
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
408
|
+
const prevFile = prev.filesByPath?.[filePath] || {};
|
|
409
|
+
return {
|
|
410
|
+
...current,
|
|
411
|
+
[tabId]: {
|
|
412
|
+
...explorerDefaultState,
|
|
413
|
+
...prev,
|
|
414
|
+
filesByPath: {
|
|
415
|
+
...(prev.filesByPath || {}),
|
|
416
|
+
[filePath]: {
|
|
417
|
+
...prevFile,
|
|
418
|
+
path: filePath,
|
|
419
|
+
loading: false,
|
|
420
|
+
error: t("Unable to load the file."),
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
[
|
|
429
|
+
attachmentSessionId,
|
|
430
|
+
explorerDefaultState,
|
|
431
|
+
explorerRef,
|
|
432
|
+
setExplorerByTab,
|
|
433
|
+
t,
|
|
434
|
+
apiFetch,
|
|
435
|
+
]
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const openFileInExplorer = useCallback(
|
|
439
|
+
(filePath) => {
|
|
440
|
+
if (!filePath) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const tabId = activeWorktreeId || "main";
|
|
444
|
+
handleViewSelect("explorer");
|
|
445
|
+
requestExplorerTree(tabId);
|
|
446
|
+
requestExplorerStatus(tabId);
|
|
447
|
+
loadExplorerFileRef.current?.(tabId, filePath);
|
|
448
|
+
},
|
|
449
|
+
[
|
|
450
|
+
activeWorktreeId,
|
|
451
|
+
handleViewSelect,
|
|
452
|
+
requestExplorerTree,
|
|
453
|
+
requestExplorerStatus,
|
|
454
|
+
loadExplorerFileRef,
|
|
455
|
+
]
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const openPathInExplorer = useCallback(
|
|
459
|
+
async (rawPath) => {
|
|
460
|
+
const tabId = activeWorktreeId || "main";
|
|
461
|
+
if (!attachmentSessionId) {
|
|
462
|
+
showToast(t("Session not found."), "error");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const normalized = normalizeOpenPath(rawPath);
|
|
466
|
+
if (!normalized) {
|
|
467
|
+
showToast(t("Path required."), "error");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
let tree = explorerRef.current[tabId]?.tree;
|
|
471
|
+
if (!Array.isArray(tree) || tree.length === 0) {
|
|
472
|
+
try {
|
|
473
|
+
await fetchExplorerChildren(tabId, "");
|
|
474
|
+
tree = explorerRef.current[tabId]?.tree;
|
|
475
|
+
} catch {
|
|
476
|
+
showToast(t("Unable to load directory."), "error");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
481
|
+
let currentPath = "";
|
|
482
|
+
let node = null;
|
|
483
|
+
for (const part of parts) {
|
|
484
|
+
const nextPath = currentPath ? `${currentPath}/${part}` : part;
|
|
485
|
+
node = findExplorerNode(tree, nextPath);
|
|
486
|
+
if (!node) {
|
|
487
|
+
showToast(t("Path not found."), "error");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (node.type === "dir" && node.children === null) {
|
|
491
|
+
try {
|
|
492
|
+
await fetchExplorerChildren(tabId, node.path);
|
|
493
|
+
tree = explorerRef.current[tabId]?.tree;
|
|
494
|
+
node = findExplorerNode(tree, nextPath);
|
|
495
|
+
if (!node) {
|
|
496
|
+
showToast(t("Path not found."), "error");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
} catch {
|
|
500
|
+
showToast(t("Unable to load directory."), "error");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
currentPath = nextPath;
|
|
505
|
+
}
|
|
506
|
+
if (!node) {
|
|
507
|
+
showToast(t("Path not found."), "error");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
handleViewSelect("explorer");
|
|
511
|
+
requestExplorerTreeRef.current?.(tabId);
|
|
512
|
+
requestExplorerStatusRef.current?.(tabId);
|
|
513
|
+
selectExplorerNode(tabId, node.path, node.type);
|
|
514
|
+
if (node.type === "dir") {
|
|
515
|
+
expandExplorerDir(tabId, node.path);
|
|
516
|
+
} else {
|
|
517
|
+
loadExplorerFileRef.current?.(tabId, node.path);
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
[
|
|
521
|
+
activeWorktreeId,
|
|
522
|
+
attachmentSessionId,
|
|
523
|
+
handleViewSelect,
|
|
524
|
+
requestExplorerTreeRef,
|
|
525
|
+
requestExplorerStatusRef,
|
|
526
|
+
normalizeOpenPath,
|
|
527
|
+
fetchExplorerChildren,
|
|
528
|
+
findExplorerNode,
|
|
529
|
+
loadExplorerFileRef,
|
|
530
|
+
selectExplorerNode,
|
|
531
|
+
expandExplorerDir,
|
|
532
|
+
explorerRef,
|
|
533
|
+
showToast,
|
|
534
|
+
t,
|
|
535
|
+
]
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const toggleExplorerDir = useCallback(
|
|
539
|
+
(tabId, dirPath) => {
|
|
540
|
+
if (!tabId || !dirPath) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const currentState = explorerRef.current[tabId] || explorerDefaultState;
|
|
544
|
+
const expanded = new Set(currentState.expandedPaths || []);
|
|
545
|
+
const willExpand = !expanded.has(dirPath);
|
|
546
|
+
setExplorerByTab((current) => {
|
|
547
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
548
|
+
const nextExpanded = new Set(prev.expandedPaths || []);
|
|
549
|
+
if (nextExpanded.has(dirPath)) {
|
|
550
|
+
nextExpanded.delete(dirPath);
|
|
551
|
+
} else {
|
|
552
|
+
nextExpanded.add(dirPath);
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
...current,
|
|
556
|
+
[tabId]: {
|
|
557
|
+
...explorerDefaultState,
|
|
558
|
+
...prev,
|
|
559
|
+
expandedPaths: Array.from(nextExpanded),
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
if (willExpand) {
|
|
564
|
+
fetchExplorerChildren(tabId, dirPath).catch(() => {
|
|
565
|
+
updateExplorerState(tabId, { error: t("Unable to load the explorer.") });
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
[
|
|
570
|
+
explorerRef,
|
|
571
|
+
explorerDefaultState,
|
|
572
|
+
setExplorerByTab,
|
|
573
|
+
fetchExplorerChildren,
|
|
574
|
+
updateExplorerState,
|
|
575
|
+
t,
|
|
576
|
+
]
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
const updateExplorerDraft = useCallback(
|
|
580
|
+
(tabId, filePath, value) => {
|
|
581
|
+
if (!tabId) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const targetPath =
|
|
585
|
+
filePath ||
|
|
586
|
+
explorerRef.current?.[tabId]?.activeFilePath ||
|
|
587
|
+
explorerRef.current?.[tabId]?.selectedPath;
|
|
588
|
+
if (!targetPath) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
setExplorerByTab((current) => {
|
|
592
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
593
|
+
const prevFile = prev.filesByPath?.[targetPath];
|
|
594
|
+
if (!prevFile) {
|
|
595
|
+
return current;
|
|
596
|
+
}
|
|
597
|
+
const nextDraft = value ?? "";
|
|
598
|
+
return {
|
|
599
|
+
...current,
|
|
600
|
+
[tabId]: {
|
|
601
|
+
...explorerDefaultState,
|
|
602
|
+
...prev,
|
|
603
|
+
filesByPath: {
|
|
604
|
+
...(prev.filesByPath || {}),
|
|
605
|
+
[targetPath]: {
|
|
606
|
+
...prevFile,
|
|
607
|
+
draftContent: nextDraft,
|
|
608
|
+
isDirty: nextDraft !== (prevFile.content || ""),
|
|
609
|
+
saveError: "",
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
},
|
|
616
|
+
[explorerRef, explorerDefaultState, setExplorerByTab]
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const saveExplorerFile = useCallback(
|
|
620
|
+
async (tabId, filePath) => {
|
|
621
|
+
if (!attachmentSessionId || !tabId) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const state = explorerRef.current?.[tabId];
|
|
625
|
+
const targetPath = filePath || state?.activeFilePath || state?.selectedPath;
|
|
626
|
+
const targetFile = targetPath ? state?.filesByPath?.[targetPath] : null;
|
|
627
|
+
if (!targetPath || !targetFile || targetFile.binary) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
setExplorerByTab((current) => {
|
|
631
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
632
|
+
const prevFile = prev.filesByPath?.[targetPath];
|
|
633
|
+
if (!prevFile) {
|
|
634
|
+
return current;
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
...current,
|
|
638
|
+
[tabId]: {
|
|
639
|
+
...explorerDefaultState,
|
|
640
|
+
...prev,
|
|
641
|
+
filesByPath: {
|
|
642
|
+
...(prev.filesByPath || {}),
|
|
643
|
+
[targetPath]: {
|
|
644
|
+
...prevFile,
|
|
645
|
+
saving: true,
|
|
646
|
+
saveError: "",
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
});
|
|
652
|
+
try {
|
|
653
|
+
const response = await apiFetch(
|
|
654
|
+
`/api/v1/sessions/${encodeURIComponent(
|
|
655
|
+
attachmentSessionId
|
|
656
|
+
)}/worktrees/${encodeURIComponent(tabId)}/file`,
|
|
657
|
+
{
|
|
658
|
+
method: "POST",
|
|
659
|
+
headers: { "Content-Type": "application/json" },
|
|
660
|
+
body: JSON.stringify({
|
|
661
|
+
path: targetPath,
|
|
662
|
+
content: targetFile.draftContent || "",
|
|
663
|
+
}),
|
|
664
|
+
}
|
|
665
|
+
);
|
|
666
|
+
if (!response.ok) {
|
|
667
|
+
throw new Error("Failed to save file");
|
|
668
|
+
}
|
|
669
|
+
setExplorerByTab((current) => {
|
|
670
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
671
|
+
const prevFile = prev.filesByPath?.[targetPath];
|
|
672
|
+
if (!prevFile) {
|
|
673
|
+
return current;
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
...current,
|
|
677
|
+
[tabId]: {
|
|
678
|
+
...explorerDefaultState,
|
|
679
|
+
...prev,
|
|
680
|
+
filesByPath: {
|
|
681
|
+
...(prev.filesByPath || {}),
|
|
682
|
+
[targetPath]: {
|
|
683
|
+
...prevFile,
|
|
684
|
+
content: prevFile.draftContent || "",
|
|
685
|
+
saving: false,
|
|
686
|
+
saveError: "",
|
|
687
|
+
isDirty: false,
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
requestExplorerStatus(tabId, true);
|
|
694
|
+
} catch {
|
|
695
|
+
setExplorerByTab((current) => {
|
|
696
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
697
|
+
const prevFile = prev.filesByPath?.[targetPath];
|
|
698
|
+
if (!prevFile) {
|
|
699
|
+
return current;
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
...current,
|
|
703
|
+
[tabId]: {
|
|
704
|
+
...explorerDefaultState,
|
|
705
|
+
...prev,
|
|
706
|
+
filesByPath: {
|
|
707
|
+
...(prev.filesByPath || {}),
|
|
708
|
+
[targetPath]: {
|
|
709
|
+
...prevFile,
|
|
710
|
+
saving: false,
|
|
711
|
+
saveError: t("Unable to save the file."),
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
};
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
[
|
|
720
|
+
attachmentSessionId,
|
|
721
|
+
explorerRef,
|
|
722
|
+
explorerDefaultState,
|
|
723
|
+
setExplorerByTab,
|
|
724
|
+
requestExplorerStatus,
|
|
725
|
+
apiFetch,
|
|
726
|
+
t,
|
|
727
|
+
]
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
const setActiveExplorerFile = useCallback(
|
|
731
|
+
(tabId, filePath) => {
|
|
732
|
+
if (!tabId || !filePath) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
updateExplorerState(tabId, {
|
|
736
|
+
activeFilePath: filePath,
|
|
737
|
+
selectedPath: filePath,
|
|
738
|
+
selectedType: "file",
|
|
739
|
+
});
|
|
740
|
+
},
|
|
741
|
+
[updateExplorerState]
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
const closeExplorerFile = useCallback(
|
|
745
|
+
(tabId, filePath) => {
|
|
746
|
+
if (!tabId || !filePath) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const state = explorerRef.current?.[tabId] || explorerDefaultState;
|
|
750
|
+
const openTabPaths = Array.isArray(state.openTabPaths) ? state.openTabPaths : [];
|
|
751
|
+
if (!openTabPaths.includes(filePath)) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const fileState = state.filesByPath?.[filePath];
|
|
755
|
+
if (fileState?.isDirty) {
|
|
756
|
+
const shouldClose = window.confirm(
|
|
757
|
+
t("You have unsaved changes. Continue without saving?")
|
|
758
|
+
);
|
|
759
|
+
if (!shouldClose) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
setExplorerByTab((current) => {
|
|
764
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
765
|
+
const prevOpenTabs = Array.isArray(prev.openTabPaths) ? prev.openTabPaths : [];
|
|
766
|
+
const targetIndex = prevOpenTabs.indexOf(filePath);
|
|
767
|
+
if (targetIndex < 0) {
|
|
768
|
+
return current;
|
|
769
|
+
}
|
|
770
|
+
const nextOpenTabs = prevOpenTabs.filter((path) => path !== filePath);
|
|
771
|
+
const nextFiles = { ...(prev.filesByPath || {}) };
|
|
772
|
+
delete nextFiles[filePath];
|
|
773
|
+
|
|
774
|
+
let nextActive = prev.activeFilePath || null;
|
|
775
|
+
if (nextActive === filePath) {
|
|
776
|
+
nextActive =
|
|
777
|
+
nextOpenTabs[targetIndex - 1] ||
|
|
778
|
+
nextOpenTabs[targetIndex] ||
|
|
779
|
+
nextOpenTabs[nextOpenTabs.length - 1] ||
|
|
780
|
+
null;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return {
|
|
784
|
+
...current,
|
|
785
|
+
[tabId]: {
|
|
786
|
+
...explorerDefaultState,
|
|
787
|
+
...prev,
|
|
788
|
+
openTabPaths: nextOpenTabs,
|
|
789
|
+
filesByPath: nextFiles,
|
|
790
|
+
activeFilePath: nextActive,
|
|
791
|
+
selectedPath: nextActive,
|
|
792
|
+
selectedType: nextActive ? "file" : null,
|
|
793
|
+
editMode: Boolean(nextActive),
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
});
|
|
797
|
+
},
|
|
798
|
+
[explorerRef, explorerDefaultState, setExplorerByTab, t]
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
const startExplorerRename = useCallback(
|
|
802
|
+
(tabId) => {
|
|
803
|
+
if (!tabId) {
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
const state = explorerRef.current?.[tabId] || explorerDefaultState;
|
|
807
|
+
const targetPath = state.selectedPath;
|
|
808
|
+
if (!targetPath) {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
updateExplorerState(tabId, {
|
|
812
|
+
renamingPath: targetPath,
|
|
813
|
+
renameDraft: pathBasename(targetPath),
|
|
814
|
+
});
|
|
815
|
+
return true;
|
|
816
|
+
},
|
|
817
|
+
[explorerRef, explorerDefaultState, updateExplorerState]
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
const cancelExplorerRename = useCallback(
|
|
821
|
+
(tabId) => {
|
|
822
|
+
if (!tabId) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
updateExplorerState(tabId, {
|
|
826
|
+
renamingPath: null,
|
|
827
|
+
renameDraft: "",
|
|
828
|
+
});
|
|
829
|
+
},
|
|
830
|
+
[updateExplorerState]
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
const updateExplorerRenameDraft = useCallback(
|
|
834
|
+
(tabId, value) => {
|
|
835
|
+
if (!tabId) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
updateExplorerState(tabId, { renameDraft: value ?? "" });
|
|
839
|
+
},
|
|
840
|
+
[updateExplorerState]
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
const submitExplorerRename = useCallback(
|
|
844
|
+
async (tabId) => {
|
|
845
|
+
if (!attachmentSessionId || !tabId) {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
const state = explorerRef.current?.[tabId] || explorerDefaultState;
|
|
849
|
+
const fromPath = state.renamingPath;
|
|
850
|
+
const renameDraft = (state.renameDraft || "").trim();
|
|
851
|
+
if (!fromPath || !renameDraft) {
|
|
852
|
+
cancelExplorerRename(tabId);
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
if (renameDraft.includes("/")) {
|
|
856
|
+
showToast(t("Path required."), "error");
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
const toPath = joinPath(pathDirname(fromPath), renameDraft);
|
|
860
|
+
if (!toPath || toPath === fromPath) {
|
|
861
|
+
cancelExplorerRename(tabId);
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const response = await apiFetch(
|
|
867
|
+
`/api/v1/sessions/${encodeURIComponent(
|
|
868
|
+
attachmentSessionId
|
|
869
|
+
)}/worktrees/${encodeURIComponent(tabId)}/file/rename`,
|
|
870
|
+
{
|
|
871
|
+
method: "POST",
|
|
872
|
+
headers: { "Content-Type": "application/json" },
|
|
873
|
+
body: JSON.stringify({ fromPath, toPath }),
|
|
874
|
+
}
|
|
875
|
+
);
|
|
876
|
+
if (!response.ok) {
|
|
877
|
+
throw new Error("Failed to rename path");
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
setExplorerByTab((current) => {
|
|
881
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
882
|
+
const nextOpenTabs = Array.from(
|
|
883
|
+
new Set(
|
|
884
|
+
(prev.openTabPaths || []).map((path) => remapPath(path, fromPath, toPath))
|
|
885
|
+
)
|
|
886
|
+
);
|
|
887
|
+
const nextFiles = {};
|
|
888
|
+
Object.entries(prev.filesByPath || {}).forEach(([path, fileState]) => {
|
|
889
|
+
nextFiles[remapPath(path, fromPath, toPath)] = fileState;
|
|
890
|
+
});
|
|
891
|
+
return {
|
|
892
|
+
...current,
|
|
893
|
+
[tabId]: {
|
|
894
|
+
...explorerDefaultState,
|
|
895
|
+
...prev,
|
|
896
|
+
openTabPaths: nextOpenTabs,
|
|
897
|
+
filesByPath: nextFiles,
|
|
898
|
+
activeFilePath: remapPath(prev.activeFilePath, fromPath, toPath),
|
|
899
|
+
selectedPath: remapPath(prev.selectedPath, fromPath, toPath),
|
|
900
|
+
renamingPath: null,
|
|
901
|
+
renameDraft: "",
|
|
902
|
+
},
|
|
903
|
+
};
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
await requestExplorerTree(tabId, true);
|
|
907
|
+
await requestExplorerStatus(tabId, true);
|
|
908
|
+
showToast(t("Renamed."), "success");
|
|
909
|
+
return true;
|
|
910
|
+
} catch {
|
|
911
|
+
showToast(t("Unable to rename."), "error");
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
},
|
|
915
|
+
[
|
|
916
|
+
attachmentSessionId,
|
|
917
|
+
explorerRef,
|
|
918
|
+
explorerDefaultState,
|
|
919
|
+
setExplorerByTab,
|
|
920
|
+
apiFetch,
|
|
921
|
+
requestExplorerTree,
|
|
922
|
+
requestExplorerStatus,
|
|
923
|
+
cancelExplorerRename,
|
|
924
|
+
showToast,
|
|
925
|
+
t,
|
|
926
|
+
]
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
const createExplorerFile = useCallback(
|
|
930
|
+
async (tabId, rawName) => {
|
|
931
|
+
if (!attachmentSessionId || !tabId) {
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
const fileName = normalizeOpenPath(rawName || "");
|
|
935
|
+
if (!fileName) {
|
|
936
|
+
showToast(t("Path required."), "error");
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
const state = explorerRef.current?.[tabId] || explorerDefaultState;
|
|
940
|
+
const selectedPath = state.selectedPath || "";
|
|
941
|
+
const selectedType = state.selectedType || null;
|
|
942
|
+
const baseDir =
|
|
943
|
+
selectedType === "dir"
|
|
944
|
+
? selectedPath
|
|
945
|
+
: selectedType === "file"
|
|
946
|
+
? pathDirname(selectedPath)
|
|
947
|
+
: "";
|
|
948
|
+
const targetPath = joinPath(baseDir, fileName);
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
const response = await apiFetch(
|
|
952
|
+
`/api/v1/sessions/${encodeURIComponent(
|
|
953
|
+
attachmentSessionId
|
|
954
|
+
)}/worktrees/${encodeURIComponent(tabId)}/file`,
|
|
955
|
+
{
|
|
956
|
+
method: "POST",
|
|
957
|
+
headers: { "Content-Type": "application/json" },
|
|
958
|
+
body: JSON.stringify({ path: targetPath, content: "" }),
|
|
959
|
+
}
|
|
960
|
+
);
|
|
961
|
+
if (!response.ok) {
|
|
962
|
+
throw new Error("Failed to create file");
|
|
963
|
+
}
|
|
964
|
+
await requestExplorerTree(tabId, true);
|
|
965
|
+
await requestExplorerStatus(tabId, true);
|
|
966
|
+
await loadExplorerFile(tabId, targetPath, true);
|
|
967
|
+
showToast(t("File created."), "success");
|
|
968
|
+
return true;
|
|
969
|
+
} catch {
|
|
970
|
+
showToast(t("Unable to create file."), "error");
|
|
971
|
+
return false;
|
|
972
|
+
}
|
|
973
|
+
},
|
|
974
|
+
[
|
|
975
|
+
attachmentSessionId,
|
|
976
|
+
explorerRef,
|
|
977
|
+
explorerDefaultState,
|
|
978
|
+
normalizeOpenPath,
|
|
979
|
+
requestExplorerTree,
|
|
980
|
+
requestExplorerStatus,
|
|
981
|
+
loadExplorerFile,
|
|
982
|
+
apiFetch,
|
|
983
|
+
showToast,
|
|
984
|
+
t,
|
|
985
|
+
]
|
|
986
|
+
);
|
|
987
|
+
|
|
988
|
+
const createExplorerFolder = useCallback(
|
|
989
|
+
async (tabId, rawName) => {
|
|
990
|
+
if (!attachmentSessionId || !tabId) {
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
const folderName = normalizeOpenPath(rawName || "");
|
|
994
|
+
if (!folderName) {
|
|
995
|
+
showToast(t("Path required."), "error");
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
const state = explorerRef.current?.[tabId] || explorerDefaultState;
|
|
999
|
+
const selectedPath = state.selectedPath || "";
|
|
1000
|
+
const selectedType = state.selectedType || null;
|
|
1001
|
+
const baseDir =
|
|
1002
|
+
selectedType === "dir"
|
|
1003
|
+
? selectedPath
|
|
1004
|
+
: selectedType === "file"
|
|
1005
|
+
? pathDirname(selectedPath)
|
|
1006
|
+
: "";
|
|
1007
|
+
const targetPath = joinPath(baseDir, folderName);
|
|
1008
|
+
|
|
1009
|
+
try {
|
|
1010
|
+
const response = await apiFetch(
|
|
1011
|
+
`/api/v1/sessions/${encodeURIComponent(
|
|
1012
|
+
attachmentSessionId
|
|
1013
|
+
)}/worktrees/${encodeURIComponent(tabId)}/folder`,
|
|
1014
|
+
{
|
|
1015
|
+
method: "POST",
|
|
1016
|
+
headers: { "Content-Type": "application/json" },
|
|
1017
|
+
body: JSON.stringify({ path: targetPath }),
|
|
1018
|
+
}
|
|
1019
|
+
);
|
|
1020
|
+
if (!response.ok) {
|
|
1021
|
+
throw new Error("Failed to create folder");
|
|
1022
|
+
}
|
|
1023
|
+
await requestExplorerTree(tabId, true);
|
|
1024
|
+
await requestExplorerStatus(tabId, true);
|
|
1025
|
+
showToast(t("Folder created."), "success");
|
|
1026
|
+
return true;
|
|
1027
|
+
} catch {
|
|
1028
|
+
showToast(t("Unable to create folder."), "error");
|
|
1029
|
+
return false;
|
|
1030
|
+
}
|
|
1031
|
+
},
|
|
1032
|
+
[
|
|
1033
|
+
attachmentSessionId,
|
|
1034
|
+
normalizeOpenPath,
|
|
1035
|
+
explorerRef,
|
|
1036
|
+
explorerDefaultState,
|
|
1037
|
+
apiFetch,
|
|
1038
|
+
requestExplorerTree,
|
|
1039
|
+
requestExplorerStatus,
|
|
1040
|
+
showToast,
|
|
1041
|
+
t,
|
|
1042
|
+
]
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
const deleteExplorerSelection = useCallback(
|
|
1046
|
+
async (tabId) => {
|
|
1047
|
+
if (!attachmentSessionId || !tabId) {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
const state = explorerRef.current?.[tabId] || explorerDefaultState;
|
|
1051
|
+
const selectedPath = state.selectedPath || "";
|
|
1052
|
+
if (!selectedPath) {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
const shouldDelete = window.confirm(
|
|
1056
|
+
t("Delete \"{{path}}\"? This action is irreversible.", {
|
|
1057
|
+
path: selectedPath,
|
|
1058
|
+
})
|
|
1059
|
+
);
|
|
1060
|
+
if (!shouldDelete) {
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
updateExplorerState(tabId, { deletingPath: selectedPath });
|
|
1065
|
+
try {
|
|
1066
|
+
const response = await apiFetch(
|
|
1067
|
+
`/api/v1/sessions/${encodeURIComponent(
|
|
1068
|
+
attachmentSessionId
|
|
1069
|
+
)}/worktrees/${encodeURIComponent(tabId)}/file/delete`,
|
|
1070
|
+
{
|
|
1071
|
+
method: "POST",
|
|
1072
|
+
headers: { "Content-Type": "application/json" },
|
|
1073
|
+
body: JSON.stringify({ path: selectedPath }),
|
|
1074
|
+
}
|
|
1075
|
+
);
|
|
1076
|
+
if (!response.ok) {
|
|
1077
|
+
throw new Error("Failed to delete path");
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
setExplorerByTab((current) => {
|
|
1081
|
+
const prev = current[tabId] || explorerDefaultState;
|
|
1082
|
+
const nextOpenTabs = (prev.openTabPaths || []).filter(
|
|
1083
|
+
(path) => path !== selectedPath && !path.startsWith(`${selectedPath}/`)
|
|
1084
|
+
);
|
|
1085
|
+
const nextFiles = {};
|
|
1086
|
+
Object.entries(prev.filesByPath || {}).forEach(([path, fileState]) => {
|
|
1087
|
+
if (path === selectedPath || path.startsWith(`${selectedPath}/`)) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
nextFiles[path] = fileState;
|
|
1091
|
+
});
|
|
1092
|
+
const nextActive =
|
|
1093
|
+
prev.activeFilePath === selectedPath ||
|
|
1094
|
+
String(prev.activeFilePath || "").startsWith(`${selectedPath}/`)
|
|
1095
|
+
? nextOpenTabs[nextOpenTabs.length - 1] || null
|
|
1096
|
+
: prev.activeFilePath;
|
|
1097
|
+
return {
|
|
1098
|
+
...current,
|
|
1099
|
+
[tabId]: {
|
|
1100
|
+
...explorerDefaultState,
|
|
1101
|
+
...prev,
|
|
1102
|
+
openTabPaths: nextOpenTabs,
|
|
1103
|
+
filesByPath: nextFiles,
|
|
1104
|
+
activeFilePath: nextActive,
|
|
1105
|
+
selectedPath: pathDirname(selectedPath) || nextActive,
|
|
1106
|
+
selectedType: pathDirname(selectedPath) ? "dir" : nextActive ? "file" : null,
|
|
1107
|
+
renamingPath: null,
|
|
1108
|
+
renameDraft: "",
|
|
1109
|
+
deletingPath: null,
|
|
1110
|
+
},
|
|
1111
|
+
};
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
await requestExplorerTree(tabId, true);
|
|
1115
|
+
await requestExplorerStatus(tabId, true);
|
|
1116
|
+
showToast(t("Deleted."), "success");
|
|
1117
|
+
return true;
|
|
1118
|
+
} catch {
|
|
1119
|
+
updateExplorerState(tabId, { deletingPath: null });
|
|
1120
|
+
showToast(t("Unable to delete."), "error");
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
},
|
|
1124
|
+
[
|
|
1125
|
+
attachmentSessionId,
|
|
1126
|
+
explorerRef,
|
|
1127
|
+
explorerDefaultState,
|
|
1128
|
+
setExplorerByTab,
|
|
1129
|
+
updateExplorerState,
|
|
1130
|
+
requestExplorerTree,
|
|
1131
|
+
requestExplorerStatus,
|
|
1132
|
+
apiFetch,
|
|
1133
|
+
showToast,
|
|
1134
|
+
t,
|
|
1135
|
+
]
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
const toggleExplorerEditMode = useCallback(
|
|
1139
|
+
(tabId, nextMode) => {
|
|
1140
|
+
if (!tabId) {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
updateExplorerState(tabId, {
|
|
1144
|
+
editMode: nextMode,
|
|
1145
|
+
});
|
|
1146
|
+
},
|
|
1147
|
+
[updateExplorerState]
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1150
|
+
useEffect(() => {
|
|
1151
|
+
requestExplorerTreeRef.current = requestExplorerTree;
|
|
1152
|
+
}, [requestExplorerTree, requestExplorerTreeRef]);
|
|
1153
|
+
|
|
1154
|
+
useEffect(() => {
|
|
1155
|
+
requestExplorerStatusRef.current = requestExplorerStatus;
|
|
1156
|
+
}, [requestExplorerStatus, requestExplorerStatusRef]);
|
|
1157
|
+
|
|
1158
|
+
useEffect(() => {
|
|
1159
|
+
loadExplorerFileRef.current = loadExplorerFile;
|
|
1160
|
+
}, [loadExplorerFile, loadExplorerFileRef]);
|
|
1161
|
+
|
|
1162
|
+
return {
|
|
1163
|
+
updateExplorerState,
|
|
1164
|
+
selectExplorerNode,
|
|
1165
|
+
openPathInExplorer,
|
|
1166
|
+
requestExplorerTree,
|
|
1167
|
+
requestExplorerStatus,
|
|
1168
|
+
loadExplorerFile,
|
|
1169
|
+
openFileInExplorer,
|
|
1170
|
+
setActiveExplorerFile,
|
|
1171
|
+
closeExplorerFile,
|
|
1172
|
+
toggleExplorerDir,
|
|
1173
|
+
toggleExplorerEditMode,
|
|
1174
|
+
updateExplorerDraft,
|
|
1175
|
+
saveExplorerFile,
|
|
1176
|
+
startExplorerRename,
|
|
1177
|
+
cancelExplorerRename,
|
|
1178
|
+
updateExplorerRenameDraft,
|
|
1179
|
+
submitExplorerRename,
|
|
1180
|
+
createExplorerFile,
|
|
1181
|
+
createExplorerFolder,
|
|
1182
|
+
deleteExplorerSelection,
|
|
1183
|
+
};
|
|
1184
|
+
}
|