返回 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
19 /** Every action a file row or preview menu can ask for. */
20 export type FileAction =
21 | "preview" | "source" | "reveal-tree"
22 | "browser" | "open-native" | "reveal-native" | "save-copy";
23
24 const CANCELLED: FileNavigationOutcome = { status: "cancelled", reason: "superseded" };
25 const asError = (reason: unknown): Error => (reason instanceof Error ? reason : new Error(String(reason)));
26 const isNavigation = (action: FileAction): action is FileNavigationParams["action"] =>
27 action === "preview" || action === "source" || action === "reveal-tree";
28
29 /**
30 * Bring the dock that presents this resource to the front and return its tab.
31 * The dock opens before the navigation record is committed, so the panel that
32 * mounts for it already has the record to read on its first render.
33 */
34 export function revealFileResourceDock(ref: FileResourceRef): string {
35 const layout = useLayoutStore.getState();
36 if (ref.hostId !== "local") {
37 const remote = useRemoteStore.getState();
38 remote.openExplorer(ref.hostId);
39 remote.setExplorerTab("files");
40 layout.setRightDockMode("remote");
41 layout.setWorkspacePanelOpen(true);
42 return useActivityBarStore.getState().openEntry("remote", "Remote");
43 }
44 layout.setRightDockMode("files");
45 layout.setWorkspacePanelOpen(true);
46 return useActivityBarStore.getState().openEntry("file", "Files");
47 }
48
49 function revealBrowserDock(): string {
50 const layout = useLayoutStore.getState();
51 layout.setRightDockMode("browser");
52 layout.setWorkspacePanelOpen(true);
53 return useActivityBarStore.getState().openEntry("browser", "Browser");
54 }
55
56 export function createFileNavigationOwner(): FileNavigationOwner {
57 return new FileNavigationOwner({ resolve: resolveFileResource, revealDock: revealFileResourceDock });
58 }
59
60 // The owner of the running app instance. The runtime registers the instance it
61 // holds; a standalone dock (a test or the browser fixture) gets its own, so the
62 // compatibility entry points below never fall back to module-level state.
63 let active: FileNavigationOwner | null = null;
64
65 export function setFileNavigationOwner(owner: FileNavigationOwner | null): void {
66 active = owner;
67 }
68
69 /** The registered instance, or null. Reading never creates one. */
70 export function currentFileNavigationOwner(): FileNavigationOwner | null {
71 return active;
72 }
73
74 export function fileNavigationOwner(): FileNavigationOwner {
75 if (!active) active = createFileNavigationOwner();
76 return active;
77 }
78
79 /** Release a preview URL this operation created but never handed to a browser tab. */
80 async function releaseBrowserPreview(url: string): Promise<void> {
81 const store = useBrowserPanelStore.getState();
82 const tab = store.tabs.find((candidate) => candidate.url === url);
83 // A tab that took the URL owns it: closing revokes it through the store's own
84 // rule. Only a URL no tab holds is revoked here, so it is revoked exactly once.
85 if (tab) await store.close(tab.id);
86 else await app.RevokeWorkspaceBrowserPreview(url).catch(() => undefined);
87 }
88
89 async function openBrowserPreview(ref: FileResourceRef): Promise<FileNavigationOutcome> {
90 if (ref.hostId !== "local") {
91 return { status: "failed", error: new Error("Remote file browser preview is unavailable; save a copy to this device first") };
92 }
93 if (ref.source === "reference") {
94 return { status: "failed", error: new Error("Answer references do not expose a browser preview") };
95 }
96 const operation = fileNavigationOwner().beginOperation({
97 sessionTabId: ref.tabId,
98 dockTabId: revealBrowserDock(),
99 });
100 const finish = (outcome: FileNavigationOutcome): FileNavigationOutcome => {
101 const owned = operation.owns();
102 operation.finish();
103 return owned ? outcome : CANCELLED;
104 };
105 let url: string;
106 try {
107 url = ref.source === "presented"
108 ? await app.CreatePresentedBrowserPreviewForTab(ref.tabId, ref.toolCallId, ref.path)
109 : await app.CreateWorkspaceBrowserPreviewForTab(ref.tabId, ref.path);
110 } catch (error) {
111 return finish({ status: "failed", error: asError(error) });
112 }
113 // A URL this command created and no browser tab ever took is its own to
114 // release, at every point where the operation may have lost its dock.
115 const cancelledAfterCreation = async (): Promise<FileNavigationOutcome> => {
116 await releaseBrowserPreview(url);
117 return finish({ status: "cancelled", reason: "superseded" });
118 };
119 try {
120 if (!operation.owns()) return await cancelledAfterCreation();
121 await waitForBrowserHost();
122 if (!operation.owns()) return await cancelledAfterCreation();
123 await useBrowserPanelStore.getState().open(url, true, operation.signal);
124 } catch (error) {
125 await releaseBrowserPreview(url);
126 return finish({ status: "failed", error: asError(error) });
127 }
128 if (!operation.owns()) return await cancelledAfterCreation();
129 return finish({ status: "opened", resource: resourceOf(ref) });
130 }
131
132 /** A direct-action receipt; dock navigation resolves identity before storing it. */
133 const resourceOf = (ref: FileResourceRef): ResolvedFileResource => ({
134 hostId: ref.hostId,
135 path: ref.path,
136 identityPath: ref.path.replace(/\\/g, "/"),
137 requestedPath: ref.path,
138 access: fileAccessContext(ref),
139 });
140
141 /**
142 * Run one action for a caller-supplied file reference. Navigation actions
143 * commit a record on the target dock; the others act on the resource directly.
144 */
145 export async function performResourceAction(ref: FileResourceRef, action: FileAction): Promise<FileNavigationOutcome> {
146 if (isNavigation(action)) {
147 // The remote tree resolves a workspace path before it can reveal it, so the
148 // resolution that guards this command lives in the owner's open command.
149 return Promise.resolve(fileNavigationOwner().open({ ref, params: { action, view: "files" } }));
150 }
151 try {
152 switch (action) {
153 case "browser":
154 return openBrowserPreview(ref);
155 case "open-native":
156 if (ref.hostId !== "local") return { status: "failed", error: new Error("This remote host does not expose a desktop opener") };
157 if (ref.source === "reference") await app.OpenReferencePathForTab(ref.tabId, ref.path);
158 else if (ref.source === "presented") await app.OpenPresentedPathForTab(ref.tabId, ref.toolCallId, ref.path);
159 else await app.OpenWorkspacePathForTab(ref.tabId, ref.path);
160 return { status: "opened", resource: resourceOf(ref) };
161 case "reveal-native":
162 if (ref.hostId !== "local") return { status: "failed", error: new Error("This remote host does not expose a desktop file manager") };
163 if (ref.source === "reference") await app.RevealReferencePathForTab(ref.tabId, ref.path);
164 else if (ref.source === "presented") await app.RevealPresentedPathForTab(ref.tabId, ref.toolCallId, ref.path);
165 else await app.RevealWorkspacePathForTab(ref.tabId, ref.path);
166 return { status: "opened", resource: resourceOf(ref) };
167 case "save-copy":
168 // The dialog completes against the path captured here; a later navigation
169 // only takes away the right to report this receipt, never the write.
170 if (ref.hostId !== "local") {
171 if (ref.source === "reference") return { status: "failed", error: new Error("This remote host does not expose a file transfer for an answer reference") };
172 if (ref.source === "presented") await app.SaveRemotePresentedFileAs(ref.tabId, ref.hostId, ref.toolCallId, ref.path);
173 else await app.SaveRemoteFileAs(ref.hostId, await resolveFileResourcePath(ref));
174 } else if (ref.source === "reference") await app.SaveReferencePathAsForTab(ref.tabId, ref.path);
175 else if (ref.source === "presented") await app.SavePresentedPathAsForTab(ref.tabId, ref.toolCallId, ref.path);
176 else await app.SaveWorkspacePathAsForTab(ref.tabId, ref.path);
177 return { status: "opened", resource: resourceOf(ref) };
178 }
179 } catch (error) {
180 return { status: "failed", error: asError(error) };
181 }
182 }
183
184 export function openResource(
185 ref: FileResourceRef,
186 options: { view: "preview" | "source" | "browser" },
187 ): Promise<FileNavigationOutcome> {
188 return performResourceAction(ref, options.view);
189 }
190
190 lines TYPESCRIPT