返回 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, 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) => ({ servers: { ...state.servers, [s.hostId]: s } })),
116
117 clearPendingFingerprint: (expected) =>
118 set((state) => {
119 if (expected && (
120 state.pendingFingerprint?.hostId !== expected.hostId ||
121 state.pendingFingerprint?.sha256 !== expected.sha256
122 )) return state;
123 return { pendingFingerprint: null };
124 }),
125
126 clearPendingSecretPrompt: (expected) =>
127 set((state) => {
128 if (expected && (
129 state.pendingSecretPrompt?.promptId !== expected.promptId ||
130 state.pendingSecretPrompt?.hostId !== expected.hostId ||
131 state.pendingSecretPrompt?.kind !== expected.kind ||
132 state.pendingSecretPrompt?.identity !== expected.identity
133 )) return state;
134 return { pendingSecretPrompt: null };
135 }),
136
137 requestStatusPopover: (hostId) =>
138 set((state) => ({
139 statusPopoverRequest: {
140 hostId,
141 nonce: (state.statusPopoverRequest?.nonce ?? 0) + 1,
142 },
143 })),
144
145 clearStatusPopoverRequest: (expected) =>
146 set((state) => (
147 state.statusPopoverRequest?.hostId === expected.hostId &&
148 state.statusPopoverRequest.nonce === expected.nonce
149 ? { statusPopoverRequest: null }
150 : state
151 )),
152
153 openExplorer: (hostId) => set({ explorerOpen: true, explorerHostId: hostId }),
154 closeExplorer: () => set({ explorerOpen: false }),
155 setExplorerTab: (tab) => set({ explorerTab: tab }),
156 }));
157
158 export function waitForRemoteConnection(hostId: string, timeoutMs = 60_000): Promise<void> {
159 const connected = (state?: RemoteConnectionStatus["state"]) => state === "connected" || state === "degraded";
160 const current = useRemoteStore.getState().statuses[hostId];
161 if (connected(current?.state)) return Promise.resolve();
162 if (current?.state === "stopped" && current.error) return Promise.reject(new Error(current.error));
163
164 return new Promise((resolve, reject) => {
165 let settled = false;
166 const finish = (err?: Error) => {
167 if (settled) return;
168 settled = true;
169 clearTimeout(timer);
170 unsubscribe();
171 if (err) reject(err);
172 else resolve();
173 };
174 const unsubscribe = useRemoteStore.subscribe((state) => {
175 const status = state.statuses[hostId];
176 if (connected(status?.state)) finish();
177 else if (status?.state === "stopped" && status.error) finish(new Error(status.error));
178 });
179 const timer = setTimeout(() => finish(new RemoteConnectionTimeoutError(hostId)), timeoutMs);
180 });
181 }
182
182 lines TYPESCRIPT