返回 DeepSeek-Reasonix
remote.ts
根目录 / desktop / frontend / src / store / remote.ts
1 // remote mirrors the kernel-owned Remote-SSH surfaces: configured hosts,
2 // per-host connection status, forward snapshots, server-bootstrap state, the
3 // pending host-key fingerprint, and the dock's host/tab selection. None of it
4 // is persisted here — the kernel hydrates hosts/statuses on mount and emits
5 // remote:* updates thereafter.
6
7 import { create } from "zustand";
8
9 import type {
10 RemoteConnectionStatus,
11 RemoteFingerprintView,
12 RemoteForwardView,
13 RemoteHostView,
14 RemoteServerView,
15 RemoteSecretPromptView,
16 } from "../lib/types";
17
18 export type RemoteExplorerTab = "files" | "ports" | "server";
19
20 export type RemoteStatusPopoverRequest = {
21 hostId: string;
22 nonce: number;
23 };
24
25 export class RemoteConnectionTimeoutError extends Error {
26 readonly hostId: string;
27
28 constructor(hostId: string) {
29 super(`Timed out connecting to ${hostId}`);
30 this.name = "RemoteConnectionTimeoutError";
31 this.hostId = hostId;
32 }
33 }
34
35 export type RemoteState = {
36 hosts: RemoteHostView[];
37 statuses: Record<string, RemoteConnectionStatus>;
38 forwards: Record<string, RemoteForwardView[]>;
39 servers: Record<string, Record<string, RemoteServerView>>;
40 pendingFingerprint: RemoteFingerprintView | null;
41 pendingSecretPrompt: RemoteSecretPromptView | null;
42 statusPopoverRequest: RemoteStatusPopoverRequest | null;
43 explorerOpen: boolean;
44 explorerHostId: string | null;
45 explorerTab: RemoteExplorerTab;
46
47 setHosts: (hosts: RemoteHostView[]) => void;
48 applyStatus: (s: RemoteConnectionStatus) => void;
49 setStatuses: (list: RemoteConnectionStatus[]) => void;
50 hydrateStatuses: (list: RemoteConnectionStatus[]) => void;
51 setForwards: (hostId: string, forwards: RemoteForwardView[]) => void;
52 setServer: (s: RemoteServerView) => void;
53 clearPendingFingerprint: (expected?: RemoteFingerprintView) => void;
54 clearPendingSecretPrompt: (expected?: RemoteSecretPromptView) => void;
55 requestStatusPopover: (hostId: string) => void;
56 clearStatusPopoverRequest: (expected: RemoteStatusPopoverRequest) => void;
57 openExplorer: (hostId: string) => void;
58 closeExplorer: () => void;
59 setExplorerTab: (tab: RemoteExplorerTab) => void;
60 };
61
62 export const useRemoteStore = create<RemoteState>((set) => ({
63 hosts: [],
64 statuses: {},
65 forwards: {},
66 servers: {},
67 pendingFingerprint: null,
68 pendingSecretPrompt: null,
69 statusPopoverRequest: null,
70 explorerOpen: false,
71 explorerHostId: null,
72 explorerTab: "files",
73
74 setHosts: (hosts) => set({ hosts }),
75
76 applyStatus: (s) =>
77 set((state) => {
78 const next: Partial<RemoteState> = {
79 statuses: { ...state.statuses, [s.hostId]: s },
80 };
81 if (s.state === "pending_hostkey" && s.fingerprint) {
82 next.pendingFingerprint = s.fingerprint;
83 } else if (state.pendingFingerprint?.hostId === s.hostId) {
84 // The pending prompt for this host resolved.
85 next.pendingFingerprint = null;
86 }
87 if (s.state === "pending_secret" && s.secretPrompt) {
88 next.pendingSecretPrompt = s.secretPrompt;
89 } else if (state.pendingSecretPrompt?.hostId === s.hostId) {
90 next.pendingSecretPrompt = null;
91 }
92 return next;
93 }),
94
95 setStatuses: (list) =>
96 set(() => {
97 const statuses: Record<string, RemoteConnectionStatus> = {};
98 for (const s of list) statuses[s.hostId] = s;
99 return { statuses };
100 }),
101
102 hydrateStatuses: (list) =>
103 set((state) => {
104 const statuses = { ...state.statuses };
105 for (const s of list) {
106 if (!statuses[s.hostId]) statuses[s.hostId] = s;
107 }
108 return { statuses };
109 }),
110
111 setForwards: (hostId, forwards) =>
112 set((state) => ({ forwards: { ...state.forwards, [hostId]: forwards } })),
113
114 setServer: (s) =>
115 set((state) => ({
116 servers: {
117 ...state.servers,
118 [s.hostId]: { ...state.servers[s.hostId], [s.workspace]: s },
119 },
120 })),
121
122 clearPendingFingerprint: (expected) =>
123 set((state) => {
124 if (expected && (
125 state.pendingFingerprint?.hostId !== expected.hostId ||
126 state.pendingFingerprint?.sha256 !== expected.sha256
127 )) return state;
128 return { pendingFingerprint: null };
129 }),
130
131 clearPendingSecretPrompt: (expected) =>
132 set((state) => {
133 if (expected && (
134 state.pendingSecretPrompt?.promptId !== expected.promptId ||
135 state.pendingSecretPrompt?.hostId !== expected.hostId ||
136 state.pendingSecretPrompt?.kind !== expected.kind ||
137 state.pendingSecretPrompt?.identity !== expected.identity
138 )) return state;
139 return { pendingSecretPrompt: null };
140 }),
141
142 requestStatusPopover: (hostId) =>
143 set((state) => ({
144 statusPopoverRequest: {
145 hostId,
146 nonce: (state.statusPopoverRequest?.nonce ?? 0) + 1,
147 },
148 })),
149
150 clearStatusPopoverRequest: (expected) =>
151 set((state) => (
152 state.statusPopoverRequest?.hostId === expected.hostId &&
153 state.statusPopoverRequest.nonce === expected.nonce
154 ? { statusPopoverRequest: null }
155 : state
156 )),
157
158 openExplorer: (hostId) => set({ explorerOpen: true, explorerHostId: hostId }),
159 closeExplorer: () => set({ explorerOpen: false }),
160 setExplorerTab: (tab) => set({ explorerTab: tab }),
161 }));
162
163 export function waitForRemoteConnection(hostId: string, timeoutMs = 60_000): Promise<void> {
164 const connected = (state?: RemoteConnectionStatus["state"]) => state === "connected" || state === "degraded";
165 const current = useRemoteStore.getState().statuses[hostId];
166 if (connected(current?.state)) return Promise.resolve();
167 if (current?.state === "stopped" && current.error) return Promise.reject(new Error(current.error));
168
169 return new Promise((resolve, reject) => {
170 let settled = false;
171 const finish = (err?: Error) => {
172 if (settled) return;
173 settled = true;
174 clearTimeout(timer);
175 unsubscribe();
176 if (err) reject(err);
177 else resolve();
178 };
179 const unsubscribe = useRemoteStore.subscribe((state) => {
180 const status = state.statuses[hostId];
181 if (connected(status?.state)) finish();
182 else if (status?.state === "stopped" && status.error) finish(new Error(status.error));
183 });
184 const timer = setTimeout(() => finish(new RemoteConnectionTimeoutError(hostId)), timeoutMs);
185 });
186 }
187
187 lines TYPESCRIPT