Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 124 additions & 3 deletions apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { EMPTY_TERMINAL_SESSION_STATE } from "@t3tools/client-runtime/state/terminal";
import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
import {
EnvironmentId,
ThreadId,
type ScopedThreadRef,
type FilesystemEntryMetadata,
} from "@t3tools/contracts";
import { Atom, AsyncResult } from "effect/reactivity";
import type { GhosttyTerminalSurfaceOptions } from "~/terminal/ghostty/surface";
import { AppAtomRegistryProvider, appAtomRegistry } from "../rpc/atomRegistry";
import { extractTerminalLinks } from "../terminal-links";
import { expect, it, vi } from "vite-plus/test";
import { act, fakeTimers, render } from "../../test/render";
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";

const fixture = vi.hoisted(() => ({
statusByThreadId: new Map<string, "running" | "exited">(),
pendingSurfaces: [] as Array<() => void>,
surfaceOptions: [] as GhosttyTerminalSurfaceOptions[],
metadata: new Map<
string,
Atom.Writable<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>
>(),
}));

// Every surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it.
Expand All @@ -19,8 +33,9 @@ const fakeSurface: object = new Proxy(
);
vi.mock("~/terminal/ghostty/surface", () => ({
GhosttyTerminalSurface: {
create: () =>
create: (_mount: HTMLElement, options: GhosttyTerminalSurfaceOptions) =>
new Promise((resolve) => {
fixture.surfaceOptions.push(options);
fixture.pendingSurfaces.push(() => resolve(fakeSurface));
}),
},
Expand Down Expand Up @@ -56,6 +71,30 @@ vi.mock("../state/server", async () => {
});
vi.mock("../state/terminal", () => ({ terminalEnvironment: { write: {}, resize: {} } }));
vi.mock("../state/preview", () => ({ previewEnvironment: { open: {} } }));
vi.mock("../state/projects", async () => {
const { Atom, AsyncResult } = await import("effect/reactivity");
return {
projectEnvironment: {
fileMetadata: ({
environmentId,
input,
}: {
environmentId: string;
input: { path: string };
}) => {
const key = JSON.stringify([environmentId, input.path]);
let atom = fixture.metadata.get(key);
if (!atom) {
atom = Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
AsyncResult.success(null),
);
fixture.metadata.set(key, atom);
}
return atom;
},
},
};
});
vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} }));
vi.mock("./preview/openTerminalLinkInPreview", () => ({ openTerminalLinkInPreview: () => {} }));
vi.mock("~/localApi", () => ({ readLocalApi: () => null }));
Expand All @@ -69,13 +108,14 @@ vi.mock("../hooks/useSettings", async () => {

function Drawer(props: {
threadRef: ScopedThreadRef;
cwd?: string;
onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void;
}) {
return (
<ThreadTerminalDrawer
threadRef={props.threadRef}
threadId={props.threadRef.threadId}
cwd="/repo"
cwd={props.cwd ?? "/repo"}
height={240}
terminalIds={["term-1"]}
activeTerminalId="term-1"
Expand Down Expand Up @@ -107,6 +147,8 @@ function setup() {
[other.threadId, "running"],
]);
fixture.pendingSurfaces = [];
fixture.surfaceOptions = [];
fixture.metadata.clear();
return { original, other };
}

Expand All @@ -127,3 +169,82 @@ it("closes an exited terminal on its own thread after navigating to one reusing
});
expect(onCloseTerminal.mock.calls).toEqual([["term-1", original]]);
});

it("only activates terminal paths confirmed to exist on their environment", async () => {
const { other: threadRef } = setup();
const key = (path: string) => JSON.stringify([threadRef.environmentId, path]);
const pending = Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
AsyncResult.initial(),
);
fixture.metadata.set(key("/repo/src/main.ts"), pending);
fixture.metadata.set(
key("/repo/src/…notes.ts"),
Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
AsyncResult.success<FilesystemEntryMetadata | null>({ kind: "file" }),
),
);
fixture.metadata.set(
key("/repo/src"),
Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
AsyncResult.success<FilesystemEntryMetadata | null>({ kind: "directory" }),
),
);
await using screen = await render(
<AppAtomRegistryProvider>
<Drawer threadRef={threadRef} onCloseTerminal={() => {}} />
</AppAtomRegistryProvider>,
);
await act(async () => {
fixture.pendingSurfaces.shift()?.();
});
const canActivate = (text: string) => fixture.surfaceOptions.at(-1)!.canActivateLink!(text);
const validate = (text: string) => fixture.surfaceOptions.at(-1)!.validateLink!(text);
const hover = (text: string | null) =>
act(() => fixture.surfaceOptions.at(-1)!.onLinkHover!(text));

expect(canActivate("https://example.com")).toBe(true);
for (const branch of ["t3/fix-html-image-details", "t3/fix-html-…mage-details"]) {
const candidates = extractTerminalLinks(branch);
expect(candidates.length).toBeGreaterThan(0);
for (const candidate of candidates) {
expect(canActivate(candidate.text)).toBe(false);
await hover(candidate.text);
expect(canActivate(candidate.text)).toBe(false);
expect(await validate(candidate.text)).toBe(false);
}
}
const pressedValidation = validate("src/main.ts:12:7");
await hover("src/main.ts:12:7");
expect(canActivate("src/main.ts:12:7")).toBe(false);
await act(async () => {
appAtomRegistry.set(pending, AsyncResult.success({ kind: "file" }));
});
expect(canActivate("src/main.ts:12:7")).toBe(true);
expect(await pressedValidation).toBe(true);
expect(canActivate("missing/file.ts")).toBe(false);
await hover("missing/file.ts");
expect(canActivate("missing/file.ts")).toBe(false);
for (const path of ["/repo/src/…notes.ts", "/repo/src"]) {
await hover(path);
expect(canActivate(path)).toBe(true);
}
await hover(null);
expect(canActivate("/repo/src")).toBe(false);
fixture.metadata.set(
key("/home/alice/notes.ts"),
Atom.make<AsyncResult.AsyncResult<FilesystemEntryMetadata | null>>(
AsyncResult.success<FilesystemEntryMetadata | null>({ kind: "file" }),
),
);
await screen.rerender(
<AppAtomRegistryProvider>
<Drawer threadRef={threadRef} cwd="/home/alice/project" onCloseTerminal={() => {}} />
</AppAtomRegistryProvider>,
);
await act(async () => {
fixture.pendingSurfaces.shift()?.();
});
expect(await validate("~/notes.ts:5:2")).toBe(true);
await hover("~/notes.ts:5:2");
expect(canActivate("~/notes.ts:5:2")).toBe(true);
});
77 changes: 64 additions & 13 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { workspaceAreaProps } from "../lib/workspaceFocus";
import { useAtomMount, useAtomValue } from "@effect/atom-react";
import {
isAtomCommandInterrupted,
executeAtomQuery,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import {
Expand All @@ -21,6 +22,7 @@ import {
} from "lucide-react";
import {
AuthPreviewOperateScope,
AuthFilesystemReadScope,
AuthOrchestrationOperateScope,
AuthTerminalOperateScope,
type ContextMenuItem,
Expand All @@ -31,10 +33,12 @@ import {
} from "@t3tools/contracts";
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
import * as Schema from "effect/Schema";
import { AsyncResult } from "effect/reactivity";
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useEffect,
useContext,
useEffectEvent,
useLayoutEffect,
useRef,
Expand Down Expand Up @@ -78,6 +82,8 @@ import {
import { readLocalApi } from "~/localApi";
import { confirmTerminalClose } from "~/lib/terminalCloseConfirm";
import { useClientSettings } from "../hooks/useSettings";
import { getFileMetadataQueryAtom } from "../hooks/useFileMetadata";
import { FileMetadataThreadContext } from "../hooks/FileMetadataThreadProvider";
import { useLocalStorage } from "../hooks/useLocalStorage";
import { attachedTerminalSessionAtom } from "../state/terminalSessions";
import { appAtomRegistry } from "../rpc/atomRegistry";
Expand Down Expand Up @@ -184,7 +190,7 @@ export function synchronizeTerminalOutput(
const selection = cells > 0 && compared ? terminal.getSelection() : "";
writeTerminalOutputUpdate(terminal, update);
if (!compared || (cells > 0 && terminal.getSelection() !== selection)) {
terminal.clearSelection();
terminal.clearSelection({ source: "output" });
}
return update.cursor;
}
Expand Down Expand Up @@ -525,10 +531,45 @@ function TerminalSessionViewport({
readEnvironmentScope(environmentId, AuthTerminalOperateScope),
);
const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
const canActivateTerminalLink = useEffectEvent(
(text: string) =>
isTerminalUrl(text) || readEnvironmentScope(environmentId, AuthOrchestrationOperateScope),
);
const canReadFilesystem = useEnvironmentScope(environmentId, AuthFilesystemReadScope);
const [linkPath, setLinkPath] = useState<string | null>(null);
const metadataThreadRef = useContext(FileMetadataThreadContext);
const linkMetadataQuery =
linkPath !== null && canOpenHostEditor && canReadFilesystem
? getFileMetadataQueryAtom(
environmentId,
resolvePathLinkTarget(linkPath, cwd),
cwd,
metadataThreadRef,
)
: null;
const canActivateTerminalLink = useEffectEvent((text: string) => {
if (isTerminalUrl(text)) return true;
if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
if (text !== linkPath || !linkMetadataQuery) return false;
const metadata = AsyncResult.getOrElse(appAtomRegistry.get(linkMetadataQuery), () => null);
return metadata?.kind === "file" || metadata?.kind === "directory";
});
const validateTerminalLink = useEffectEvent(async (text: string) => {
if (isTerminalUrl(text)) return true;
if (
!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
!readEnvironmentScope(environmentId, AuthFilesystemReadScope)
)
return false;
const query = getFileMetadataQueryAtom(
environmentId,
resolvePathLinkTarget(text, cwd),
cwd,
metadataThreadRef,
);
if (!query) return false;
const result = await executeAtomQuery(appAtomRegistry, query, { reportFailure: false });
return (
result._tag === "Success" &&
(result.value?.kind === "file" || result.value?.kind === "directory")
);
});
const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
const openInPreferredEditor = useOpenInPreferredEditor(
environmentId,
Expand Down Expand Up @@ -636,14 +677,18 @@ function TerminalSessionViewport({
consumePendingTerminalFocus(threadRef, terminalId);
}, [canOperateTerminal, terminalId, threadRef]);

// Links re-read the grant on activation; only the hovered link's styling needs a nudge.
const linkActivationScopeRef = useRef(canOpenHostEditor);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refreshes link activation styling on the imperative terminal surface when the editor grant changes
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refreshes the imperative terminal's hovered link after filesystem metadata or editor permissions change
useEffect(() => {
if (linkActivationScopeRef.current === canOpenHostEditor) return;
linkActivationScopeRef.current = canOpenHostEditor;
terminalRef.current?.refreshLinkActivation();
}, [canOpenHostEditor]);
if (!linkMetadataQuery) {
terminalRef.current?.refreshLinkActivation();
return;
}
return appAtomRegistry.subscribe(
linkMetadataQuery,
() => terminalRef.current?.refreshLinkActivation(),
{ immediate: true },
);
}, [linkMetadataQuery]);

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes visibility into the terminal surface before paint and consumes focus requests made while hidden
useLayoutEffect(() => {
Expand Down Expand Up @@ -684,6 +729,8 @@ function TerminalSessionViewport({
beforeKey: (event) => handleBeforeKey(event),
onLinkActivate: (text, event) => handleLinkActivate(text, event),
canActivateLink: (text) => canActivateTerminalLink(text),
validateLink: (text) => validateTerminalLink(text),
onLinkHover: (text) => setLinkPath(text !== null && !isTerminalUrl(text) ? text : null),
// Surface listeners start before WASM loading finishes and the terminal handler exists.
onContextMenu: (event) => {
if (terminalRef.current) void showTerminalContextMenu(event);
Expand Down Expand Up @@ -992,7 +1039,11 @@ function TerminalSessionViewport({
}

function handleLinkActivate(text: string, event: MouseEvent): void {
if (!canActivateTerminalLink(text)) return;
if (
!isTerminalUrl(text) &&
!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
)
return;
const latestTerminal = terminalRef.current;
if (!latestTerminal) return;
if (isTerminalUrl(text)) {
Expand Down
31 changes: 23 additions & 8 deletions apps/web/src/hooks/useFileMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { AuthFilesystemReadScope, type EnvironmentId } from "@t3tools/contracts";
import {
AuthFilesystemReadScope,
type EnvironmentId,
type ScopedThreadRef,
} from "@t3tools/contracts";
import { resolvePathLinkTarget, splitFilePathPosition } from "@t3tools/shared/fileLinks";
import {
isAbsolutePath,
Expand All @@ -12,29 +16,40 @@ import { projectEnvironment } from "../state/projects";
import { useEnvironmentQuery } from "../state/query";
import { FileMetadataThreadContext } from "./FileMetadataThreadProvider";

export function useFileMetadata(
environmentId: EnvironmentId | null | undefined,
export function getFileMetadataQueryAtom(
environmentId: EnvironmentId,
path: string,
cwd?: string,
threadRef?: ScopedThreadRef | null,
) {
const canRead = useEnvironmentScope(environmentId ?? null, AuthFilesystemReadScope);
const threadRef = useContext(FileMetadataThreadContext);
const hostPath = splitFilePathPosition(path).path;
const targetPath = normalizeProjectPathForComparison(
hostPath.startsWith("~/") || !cwd ? hostPath : resolvePathLinkTarget(hostPath, cwd),
);
const absolute = isAbsolutePath(targetPath) || targetPath.startsWith("~/");
return useEnvironmentQuery(
return (
// Servers stat what they are asked about, and a network path leaks the
// Windows user's NTLM credentials to its host before any chip is clicked.
environmentId && canRead && absolute && !isNetworkPath(targetPath) && targetPath.length <= 512
absolute && !isNetworkPath(targetPath) && targetPath.length <= 512
? projectEnvironment.fileMetadata({
environmentId,
input: {
path: targetPath,
...(threadRef?.environmentId === environmentId ? { threadId: threadRef.threadId } : {}),
},
})
: null,
: null
);
}

export function useFileMetadata(
environmentId: EnvironmentId | null | undefined,
path: string,
cwd?: string,
) {
const canRead = useEnvironmentScope(environmentId ?? null, AuthFilesystemReadScope);
const threadRef = useContext(FileMetadataThreadContext);
return useEnvironmentQuery(
environmentId && canRead ? getFileMetadataQueryAtom(environmentId, path, cwd, threadRef) : null,
).data;
}
Loading
Loading