返回 DeepSeek-Reasonix
fileNavigationCommands.ts
根目录 / desktop / frontend / src / lib / fileNavigationCommands.ts
1 import { app } from "./bridge";
2 import { useBrowserPanelStore, waitForBrowserHost } from "./browserPanelStore";
3 import { useActivityBarStore } from "../store/activityBar";
4 import { useLayoutStore } from "../store/layout";
5 import { useRemoteStore } from "../store/remote";
6 import {
7 fileAccessContext,
8 resolveFileResource,
9 resolveFileResourcePath,
10 type FileResourceRef,
11 type ResolvedFileResource,
12 } from "./fileResource";
13 import {
14 FileNavigationOwner,
15 type FileNavigationOutcome,
16 type FileNavigationParams,
17 } from "./fileNavigationOwner";
18 import { pathExtension } from "./filePaths";
19 import { bindFileBrowserPreview } from "./fileBrowserPreviewBindings";
20
21 /** Every action a file row or preview menu can ask for. */
22 export type FileAction =
23 | "preview" | "source" | "reveal-tree"
24 | "browser" | "open-native" | "reveal-native" | "save-copy";
25
26 const CANCELLED: FileNavigationOutcome = { status: "cancelled", reason: "superseded" };
27 const asError = (reason: unknown): Error => (reason instanceof Error ? reason : new Error(String(reason)));
28 const isNavigation = (action: FileAction): action is FileNavigationParams["action"] =>
29 action === "preview" || action === "source" || action === "reveal-tree";
30
31 export const isHTMLResource = (ref: Pick<FileResourceRef, "hostId" | "path">): boolean =>
32 ref.hostId === "local" && (pathExtension(ref.path) === "html" || pathExtension(ref.path) === "htm");
33
34 let previewOperationSequence = 0;
35 function previewOperationID(ref: FileResourceRef): string {
36 previewOperationSequence += 1;
37 return `file-preview-${Date.now().toString(36)}-${previewOperationSequence.toString(36)}-${ref.source}`;
38 }
39
40 /**
41 * Bring the dock that presents this resource to the front and return its tab.
42 * The dock opens before the navigation record is committed, so the panel that
43 * mounts for it already has the record to read on its first render.
44 */
45 export function revealFileResourceDock(ref: FileResourceRef): string {
46 const layout = useLayoutStore.getState();
47 if (ref.hostId !== "local") {
48 const remote = useRemoteStore.getState();
49 remote.openExplorer(ref.hostId);
50 remote.setExplorerTab("files");
51 layout.setRightDockMode("remote");
52 layout.setWorkspacePanelOpen(true);
53 return useActivityBarStore.getState().openEntry("remote", "Remote");
54 }
55 layout.setRightDockMode("files");
56 layout.setWorkspacePanelOpen(true);
57 return useActivityBarStore.getState().openEntry("file", "Files");
58 }
59
60 function revealBrowserDock(): string {
61 const layout = useLayoutStore.getState();
62 layout.setRightDockMode("browser");
63 layout.setWorkspacePanelOpen(true);
64 return useActivityBarStore.getState().openEntry("browser", "Browser");
65 }
66
67 export function createFileNavigationOwner(): FileNavigationOwner {
68 return new FileNavigationOwner({ resolve: resolveFileResource, revealDock: revealFileResourceDock });
69 }
70
71 // The owner of the running app instance. The runtime registers the instance it
72 // holds; a standalone dock (a test or the browser fixture) gets its own, so the
73 // compatibility entry points below never fall back to module-level state.
74 let active: FileNavigationOwner | null = null;
75
76 export function setFileNavigationOwner(owner: FileNavigationOwner | null): void {
77 active = owner;
78 }
79
80 /** The registered instance, or null. Reading never creates one. */
81 export function currentFileNavigationOwner(): FileNavigationOwner | null {
82 return active;
83 }
84
85 export function fileNavigationOwner(): FileNavigationOwner {
86 if (!active) active = createFileNavigationOwner();
87 return active;
88 }
89
90 /** Release a preview URL this operation created but never handed to a browser tab. */
91 async function releaseBrowserPreview(url: string): Promise<void> {
92 const store = useBrowserPanelStore.getState();
93 const tab = store.tabs.find((candidate) => candidate.url === url);
94 // A tab that took the URL owns it: closing revokes it through the store's own
95 // rule. Only a URL no tab holds is revoked here, so it is revoked exactly once.
96 if (tab) await store.close(tab.id);
97 else await app.RevokeWorkspaceBrowserPreview(url).catch(() => undefined);
98 }
99
100 async function openBrowserPreview(ref: FileResourceRef, userInitiated = false): Promise<FileNavigationOutcome> {
101 if (ref.hostId !== "local") {
102 return { status: "failed", error: new Error("Remote file browser preview is unavailable; save a copy to this device first") };
103 }
104 const operation = fileNavigationOwner().beginOperation({
105 sessionTabId: ref.tabId,
106 dockTabId: revealBrowserDock(),
107 });
108 const finish = (outcome: FileNavigationOutcome): FileNavigationOutcome => {
109 const owned = operation.owns();
110 operation.finish();
111 return owned ? outcome : CANCELLED;
112 };
113 const openFilePreview = (app as Partial<typeof app>).OpenFileBrowserPreviewForTab;
114 if (typeof openFilePreview === "function") {
115 try {
116 if (!operation.owns()) return finish(CANCELLED);
117 const result = await openFilePreview.call(app, ref.tabId, {
118 source: ref.source,
119 path: ref.path,
120 toolCallId: ref.source === "presented" ? ref.toolCallId : undefined,
121 operationId: previewOperationID(ref),
122 expectedSessionGeneration: ref.sessionGeneration,
123 userInitiated,
124 });
125 if (!operation.owns()) {
126 await waitForBrowserHost().then((host) => host.close(result.tabId)).catch(() => undefined);
127 return finish(CANCELLED);
128 }
129 await waitForBrowserHost();
130 await useBrowserPanelStore.getState().refreshAndActivate(result.tabId);
131 bindFileBrowserPreview(result.tabId, ref, result.url);
132 if (result.error || result.status === "failed") {
133 return finish({ status: "failed", error: new Error(result.error || "The built-in browser could not load this preview") });
134 }
135 return finish({ status: "opened", resource: resourceOf(ref) });
136 } catch (error) {
137 return finish({ status: "failed", error: asError(error) });
138 }
139 }
140
141 if (ref.source === "reference") {
142 return finish({ status: "failed", error: new Error("This desktop version cannot open answer references in the built-in browser") });
143 }
144 let url: string;
145 try {
146 url = ref.source === "presented"
147 ? await app.CreatePresentedBrowserPreviewForTab(ref.tabId, ref.toolCallId, ref.path)
148 : await app.CreateWorkspaceBrowserPreviewForTab(ref.tabId, ref.path);
149 } catch (error) {
150 return finish({ status: "failed", error: asError(error) });
151 }
152 // A URL this command created and no browser tab ever took is its own to
153 // release, at every point where the operation may have lost its dock.
154 const cancelledAfterCreation = async (): Promise<FileNavigationOutcome> => {
155 await releaseBrowserPreview(url);
156 return finish({ status: "cancelled", reason: "superseded" });
157 };
158 try {
159 if (!operation.owns()) return await cancelledAfterCreation();
160 await waitForBrowserHost();
161 if (!operation.owns()) return await cancelledAfterCreation();
162 const tab = await useBrowserPanelStore.getState().open(url, true, operation.signal, ref.tabId);
163 bindFileBrowserPreview(tab.id, ref, url);
164 } catch (error) {
165 await releaseBrowserPreview(url);
166 return finish({ status: "failed", error: asError(error) });
167 }
168 if (!operation.owns()) return await cancelledAfterCreation();
169 return finish({ status: "opened", resource: resourceOf(ref) });
170 }
171
172 /** Refresh a bound file preview without returning a user-owned tab to Agent control. */
173 export function refreshBrowserResource(ref: FileResourceRef): Promise<FileNavigationOutcome> {
174 return openBrowserPreview(ref, true);
175 }
176
177 /** A direct-action receipt; dock navigation resolves identity before storing it. */
178 const resourceOf = (ref: FileResourceRef): ResolvedFileResource => ({
179 hostId: ref.hostId,
180 path: ref.path,
181 identityPath: ref.path.replace(/\\/g, "/"),
182 requestedPath: ref.path,
183 access: fileAccessContext(ref),
184 });
185
186 /**
187 * Run one action for a caller-supplied file reference. Navigation actions
188 * commit a record on the target dock; the others act on the resource directly.
189 */
190 export async function performResourceAction(ref: FileResourceRef, action: FileAction): Promise<FileNavigationOutcome> {
191 if (isNavigation(action)) {
192 // The remote tree resolves a workspace path before it can reveal it, so the
193 // resolution that guards this command lives in the owner's open command.
194 return Promise.resolve(fileNavigationOwner().open({ ref, params: { action, view: "files" } }));
195 }
196 try {
197 switch (action) {
198 case "browser":
199 return openBrowserPreview(ref);
200 case "open-native":
201 if (ref.hostId !== "local") return { status: "failed", error: new Error("This remote host does not expose a desktop opener") };
202 if (ref.source === "reference") await app.OpenReferencePathForTab(ref.tabId, ref.path);
203 else if (ref.source === "presented") await app.OpenPresentedPathForTab(ref.tabId, ref.toolCallId, ref.path);
204 else await app.OpenWorkspacePathForTab(ref.tabId, ref.path);
205 return { status: "opened", resource: resourceOf(ref) };
206 case "reveal-native":
207 if (ref.hostId !== "local") return { status: "failed", error: new Error("This remote host does not expose a desktop file manager") };
208 if (ref.source === "reference") await app.RevealReferencePathForTab(ref.tabId, ref.path);
209 else if (ref.source === "presented") await app.RevealPresentedPathForTab(ref.tabId, ref.toolCallId, ref.path);
210 else await app.RevealWorkspacePathForTab(ref.tabId, ref.path);
211 return { status: "opened", resource: resourceOf(ref) };
212 case "save-copy":
213 // The dialog completes against the path captured here; a later navigation
214 // only takes away the right to report this receipt, never the write.
215 if (ref.hostId !== "local") {
216 if (ref.source === "reference") return { status: "failed", error: new Error("This remote host does not expose a file transfer for an answer reference") };
217 if (ref.source === "presented") await app.SaveRemotePresentedFileAs(ref.tabId, ref.hostId, ref.toolCallId, ref.path);
218 else await app.SaveRemoteFileAs(ref.hostId, await resolveFileResourcePath(ref));
219 } else if (ref.source === "reference") await app.SaveReferencePathAsForTab(ref.tabId, ref.path);
220 else if (ref.source === "presented") await app.SavePresentedPathAsForTab(ref.tabId, ref.toolCallId, ref.path);
221 else await app.SaveWorkspacePathAsForTab(ref.tabId, ref.path);
222 return { status: "opened", resource: resourceOf(ref) };
223 }
224 } catch (error) {
225 return { status: "failed", error: asError(error) };
226 }
227 }
228
229 export function openResource(
230 ref: FileResourceRef,
231 options: { view: "preview" | "source" | "browser" },
232 ): Promise<FileNavigationOutcome> {
233 return performResourceAction(ref, options.view === "preview" && isHTMLResource(ref) ? "browser" : options.view);
234 }
235
235 lines TYPESCRIPT