Open History to see the last commit
1import { EMPTY_TERMINAL_SESSION_STATE } from "@t3tools/client-runtime/state/terminal";
2import {
3 EnvironmentId,
4 ThreadId,
5 type ScopedThreadRef,
6 type FilesystemEntryMetadata,
7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
9import { compileResolvedKeybindingsConfig, DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings";
10import { Atom, AsyncResult } from "effect/reactivity";
11import type { GhosttyTerminalSurfaceOptions } from "~/terminal/ghostty/surface";
12import { AppAtomRegistryProvider, appAtomRegistry } from "../rpc/atomRegistry";
13import { useRightPanelStore } from "../rightPanelStore";
14import { useTerminalUiStateStore } from "../terminalUiStateStore";
15import { extractTerminalLinks } from "../terminal-links";
16import { expect, it, vi } from "vite-plus/test";
17import { act, fakeTimers, render } from "../../test/render";
18import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
19
20const fixture = vi.hoisted(() => ({
21 statusByThreadId: new Map<string, "running" | "exited">(),
22 pendingSurfaces: [] as Array<() => void>,
23 surfaceOptions: [] as GhosttyTerminalSurfaceOptions[],
24 metadata: new Map<
25 string,
26 Atom.Writable<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>
27 >(),
28}));
29
30// Every surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it.
31const fakeSurface: object = new Proxy(
32 { input: document.createElement("textarea") },
33 {
34 get: (target, key) =>
35 key === "then" ? undefined : key in target ? Reflect.get(target, key) : () => undefined,
36 },
37);
38vi.mock("~/terminal/ghostty/surface", () => ({
39 GhosttyTerminalSurface: {
40 create: (_mount: HTMLElement, options: GhosttyTerminalSurfaceOptions) =>
41 new Promise((resolve) => {
42 fixture.surfaceOptions.push(options);
43 fixture.pendingSurfaces.push(() => resolve(fakeSurface));
44 }),
45 },
46}));
47vi.mock("../state/terminalSessions", async () => {
48 const Atom = await import("effect/reactivity/Atom");
49 const sessions = Atom.family((_environmentId: EnvironmentId) =>
50 Atom.family((threadId: ThreadId) =>
51 Atom.make({
52 ...EMPTY_TERMINAL_SESSION_STATE,
53 status: fixture.statusByThreadId.get(threadId) ?? "closed",
54 version: 1,
55 }),
56 ),
57 );
58 return {
59 attachedTerminalSessionAtom: (environmentId: EnvironmentId, terminal: { threadId: ThreadId }) =>
60 sessions(environmentId)(terminal.threadId),
61 };
62});
63vi.mock("../state/session", () => ({
64 useEnvironmentScope: () => true,
65 readEnvironmentScope: () => true,
66}));
67vi.mock("../state/use-atom-command", () => ({
68 useAtomCommand: () => async () => ({ _tag: "Success" }),
69}));
70// The commands are replaced above; importing their runtime would connect a client.
71vi.mock("../state/server", async () => {
72 const Atom = await import("effect/reactivity/Atom");
73 const config = Atom.make(null);
74 return { serverEnvironment: { configValueAtom: () => config } };
75});
76vi.mock("../state/terminal", () => ({ terminalEnvironment: { write: {}, resize: {} } }));
77vi.mock("../state/preview", () => ({ previewEnvironment: { open: {} } }));
78vi.mock("../state/projects", async () => {
79 const { Atom, AsyncResult } = await import("effect/reactivity");
80 return {
81 projectEnvironment: {
82 fileMetadata: ({
83 environmentId,
84 input,
85 }: {
86 environmentId: string;
87 input: { path: string };
88 }) => {
89 const key = JSON.stringify([environmentId, input.path]);
90 let atom = fixture.metadata.get(key);
91 if (!atom) {
92 atom = Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
93 AsyncResult.success(null),
94 );
95 fixture.metadata.set(key, atom);
96 }
97 return atom;
98 },
99 },
100 };
101});
102vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} }));
103vi.mock("./preview/openTerminalLinkInPreview", () => ({ openTerminalLinkInPreview: () => {} }));
104vi.mock("~/localApi", () => ({ readLocalApi: () => null }));
105vi.mock("../hooks/useSettings", async () => {
106 const { DEFAULT_CLIENT_SETTINGS } = await import("@t3tools/contracts/settings");
107 return {
108 useClientSettings: <T,>(select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => T) =>
109 select(DEFAULT_CLIENT_SETTINGS),
110 };
111});
112
113function Drawer(props: {
114 threadRef: ScopedThreadRef;
115 cwd?: string;
116 onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void;
117 keybindings?: ResolvedKeybindingsConfig;
118}) {
119 return (
120 <ThreadTerminalDrawer
121 threadRef={props.threadRef}
122 threadId={props.threadRef.threadId}
123 cwd={props.cwd ?? "/repo"}
124 height={240}
125 terminalIds={["term-1"]}
126 activeTerminalId="term-1"
127 terminalGroups={[{ id: "group-1", terminalIds: ["term-1"] }]}
128 activeTerminalGroupId="group-1"
129 onSplitTerminal={() => {}}
130 onSplitTerminalVertical={() => {}}
131 onNewTerminal={() => {}}
132 onActiveTerminalChange={() => {}}
133 onCloseTerminal={props.onCloseTerminal}
134 onHeightChange={() => {}}
135 onAddTerminalContext={() => {}}
136 keybindings={props.keybindings ?? []}
137 />
138 );
139}
140
141function setup() {
142 const original: ScopedThreadRef = {
143 environmentId: EnvironmentId.make("environment-a"),
144 threadId: ThreadId.make("thread-a"),
145 };
146 const other: ScopedThreadRef = {
147 environmentId: EnvironmentId.make("environment-b"),
148 threadId: ThreadId.make("thread-b"),
149 };
150 fixture.statusByThreadId = new Map([
151 [original.threadId, "exited"],
152 [other.threadId, "running"],
153 ]);
154 fixture.pendingSurfaces = [];
155 fixture.surfaceOptions = [];
156 fixture.metadata.clear();
157 useRightPanelStore.setState(useRightPanelStore.getInitialState());
158 useTerminalUiStateStore.setState(useTerminalUiStateStore.getInitialState());
159 return { original, other };
160}
161
162it("closes an exited terminal on its own thread after navigating to one reusing its ID", async () => {
163 const { original, other } = setup();
164 const onCloseTerminal = vi.fn();
165 await using screen = await render(
166 <Drawer threadRef={original} onCloseTerminal={onCloseTerminal} />,
167 );
168 using _timers = fakeTimers();
169 // The exit is seen once the surface loads, and its close waits for the next task.
170 await act(async () => {
171 fixture.pendingSurfaces.shift()?.();
172 });
173 await screen.rerender(<Drawer threadRef={other} onCloseTerminal={onCloseTerminal} />);
174 await act(async () => {
175 vi.runOnlyPendingTimers();
176 });
177 expect(onCloseTerminal.mock.calls).toEqual([["term-1", original]]);
178});
179
180// Thread navigation defaults have no `!terminalFocus`, so they apply in a focused terminal.
181it("hands thread navigation shortcuts from a focused terminal back to the app", async () => {
182 const { other: threadRef } = setup();
183 using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64");
184 useRightPanelStore.getState().openBrowser(threadRef, null);
185 await using _screen = await render(
186 <Drawer
187 threadRef={threadRef}
188 onCloseTerminal={() => {}}
189 keybindings={compileResolvedKeybindingsConfig([
190 ...DEFAULT_KEYBINDINGS,
191 { key: "ctrl+r", command: "thread.next", when: "!previewOpen" },
192 { key: "ctrl+g", command: "thread.previous", when: "terminalOpen" },
193 ])}
194 />,
195 );
196 await act(async () => {
197 fixture.pendingSurfaces.shift()?.();
198 });
199 const terminalHandles = (key: string, code: string, shiftKey = true) =>
200 fixture.surfaceOptions
201 .at(-1)!
202 .beforeKey(new KeyboardEvent("keydown", { key, code, ctrlKey: true, shiftKey }));
203
204 expect([terminalHandles("{", "BracketLeft"), terminalHandles("}", "BracketRight")]).toEqual([
205 false,
206 false,
207 ]);
208 expect(terminalHandles("C", "KeyC")).toBe(true);
209 // Sidebar sees the open preview and the closed drawer and rejects these, so the shell keeps them.
210 expect([terminalHandles("r", "KeyR", false), terminalHandles("g", "KeyG", false)]).toEqual([
211 true,
212 true,
213 ]);
214});
215
216it("only activates terminal paths confirmed to exist on their environment", async () => {
217 const { other: threadRef } = setup();
218 const key = (path: string) => JSON.stringify([threadRef.environmentId, path]);
219 const pending = Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
220 AsyncResult.initial(),
221 );
222 fixture.metadata.set(key("/repo/src/main.ts"), pending);
223 fixture.metadata.set(
224 key("/repo/src/…notes.ts"),
225 Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
226 AsyncResult.success<FilesystemEntryMetadata | null>({ kind: "file" }),
227 ),
228 );
229 fixture.metadata.set(
230 key("/repo/src"),
231 Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
232 AsyncResult.success<FilesystemEntryMetadata | null>({ kind: "directory" }),
233 ),
234 );
235 await using screen = await render(
236 <AppAtomRegistryProvider>
237 <Drawer threadRef={threadRef} onCloseTerminal={() => {}} />
238 </AppAtomRegistryProvider>,
239 );
240 await act(async () => {
241 fixture.pendingSurfaces.shift()?.();
242 });
243 const canActivate = (text: string) => fixture.surfaceOptions.at(-1)!.canActivateLink!(text);
244 const validate = (text: string) => fixture.surfaceOptions.at(-1)!.validateLink!(text);
245 const hover = (text: string | null) =>
246 act(() => fixture.surfaceOptions.at(-1)!.onLinkHover!(text));
247
248 expect(canActivate("https://example.com")).toBe(true);
249 for (const branch of ["t3/fix-html-image-details", "t3/fix-html-…mage-details"]) {
250 const candidates = extractTerminalLinks(branch);
251 expect(candidates.length).toBeGreaterThan(0);
252 for (const candidate of candidates) {
253 expect(canActivate(candidate.text)).toBe(false);
254 await hover(candidate.text);
255 expect(canActivate(candidate.text)).toBe(false);
256 expect(await validate(candidate.text)).toBe(false);
257 }
258 }
259 const pressedValidation = validate("src/main.ts:12:7");
260 await hover("src/main.ts:12:7");
261 expect(canActivate("src/main.ts:12:7")).toBe(false);
262 await act(async () => {
263 appAtomRegistry.set(pending, AsyncResult.success({ kind: "file" }));
264 });
265 expect(canActivate("src/main.ts:12:7")).toBe(true);
266 expect(await pressedValidation).toBe(true);
267 expect(canActivate("missing/file.ts")).toBe(false);
268 await hover("missing/file.ts");
269 expect(canActivate("missing/file.ts")).toBe(false);
270 for (const path of ["/repo/src/…notes.ts", "/repo/src"]) {
271 await hover(path);
272 expect(canActivate(path)).toBe(true);
273 }
274 await hover(null);
275 expect(canActivate("/repo/src")).toBe(false);
276 fixture.metadata.set(
277 key("/home/alice/notes.ts"),
278 Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
279 AsyncResult.success<FilesystemEntryMetadata | null>({ kind: "file" }),
280 ),
281 );
282 await screen.rerender(
283 <AppAtomRegistryProvider>
284 <Drawer threadRef={threadRef} cwd="/home/alice/project" onCloseTerminal={() => {}} />
285 </AppAtomRegistryProvider>,
286 );
287 await act(async () => {
288 fixture.pendingSurfaces.shift()?.();
289 });
290 expect(await validate("~/notes.ts:5:2")).toBe(true);
291 await hover("~/notes.ts:5:2");
292 expect(canActivate("~/notes.ts:5:2")).toBe(true);
293});