Skip to content
Closed
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
73 changes: 73 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,79 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

// A `path:20-40` span comes from a chat reference to a range of lines. The
// editor has no span argument, so it opens at the range's first line rather
// than failing on a path that does not exist.
it.effect("opens a line span at its first line", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
yield* fileSystem.writeFileString(path.join(binDir, "idea"), "#!/bin/sh\n");
yield* fileSystem.chmod(path.join(binDir, "idea"), 0o755);

let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({ editor: "idea", cwd: "/workspace/src/index.ts:20-40" });
}).pipe(
Effect.provide(
testLayer({
platform: "linux",
env: { PATH: binDir },
onSpawn: (command) => {
spawned = command;
},
}),
),
);

assert.ok(spawned);
assert.deepEqual(spawned.args, ["--line", "20", "/workspace/src/index.ts"]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

// `--goto` takes `path:line[:column]`, so a span has to be rebuilt rather than
// passed through; the raw `path:20-40` would be read as part of the filename.
it.effect("hands goto editors a span as path:line", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
yield* fileSystem.writeFileString(path.join(binDir, "code"), "#!/bin/sh\n");
yield* fileSystem.chmod(path.join(binDir, "code"), 0o755);

const launch = (target: string) =>
Effect.gen(function* () {
let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({ editor: "vscode", cwd: target });
}).pipe(
Effect.provide(
testLayer({
platform: "linux",
env: { PATH: binDir },
onSpawn: (command) => {
spawned = command;
},
}),
),
);
return spawned;
});

const spanLaunch = yield* launch("/workspace/src/index.ts:20-40");
assert.ok(spanLaunch);
assert.deepEqual(spanLaunch.args, ["--goto", "/workspace/src/index.ts:20"]);

// Every other shape still reaches the editor exactly as before.
const columnLaunch = yield* launch("/workspace/src/index.ts:20:5");
assert.ok(columnLaunch);
assert.deepEqual(columnLaunch.args, ["--goto", "/workspace/src/index.ts:20:5"]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("discovers editors through the service API", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
18 changes: 16 additions & 2 deletions apps/server/src/process/externalLauncher.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ interface TargetPathAndPosition {
readonly column: Option.Option<string>;
}

const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
// `path:12`, `path:12:5`, and the span form `path:12-40`. An editor opens at a
// span's first line, so its end is matched to be stripped, never captured.
const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+)|-\d+)?$/;
Comment thread
cursor[bot] marked this conversation as resolved.
const POWERSHELL_ARGUMENTS_PREFIX = [
"-NoProfile",
"-NonInteractive",
Expand Down Expand Up @@ -133,8 +135,20 @@ function resolveCommandEditorArgs(
switch (editor.launchStyle) {
case "direct-path":
return [target];
// Rebuilt from the parse rather than passed through: a `path:20-40` span
// is not a form these CLIs accept, and the rebuilt string is identical to
// `target` for every other shape.
case "goto":
return Option.isSome(parsedTarget) ? ["--goto", target] : [target];
return Option.match(parsedTarget, {
onNone: () => [target],
onSome: ({ path, line, column }) => [
"--goto",
`${path}:${line}${Option.match(column, {
onNone: () => "",
onSome: (value) => `:${value}`,
})}`,
],
});
case "line-column":
return Option.match(parsedTarget, {
onNone: () => [target],
Expand Down
65 changes: 62 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ import { usePreparedConnection } from "../state/session";
import { previewEnvironment } from "../state/preview";
import { useAtomCommand } from "../state/use-atom-command";
import { useAtomQueryRunner } from "../state/use-atom-query-runner";
import { projectEnvironment } from "../state/projects";
import {
claimWorkspaceBasenameLookup,
needsWorkspaceBasenameLookup,
pickWorkspaceBasenameMatch,
WORKSPACE_BASENAME_LOOKUP_LIMIT,
} from "../workspaceBasenameLookup";
import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
import { isPreviewSupportedInRuntime } from "../previewStateStore";
Expand Down Expand Up @@ -786,11 +793,17 @@ interface MarkdownFileLinkProps {
displayPath: string;
workspaceRelativePath: string | null;
line?: number | undefined;
endLine?: number | undefined;
label: string;
copyMarkdown: string;
theme: "light" | "dark";
threadRef?: ScopedThreadRef | undefined;
onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>;
onOpenInPanel: (
workspaceRelativePath: string,
line: number | undefined,
endLine: number | undefined,
) => void;
onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
className?: string | undefined;
}
Expand Down Expand Up @@ -1088,11 +1101,13 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
displayPath,
workspaceRelativePath,
line,
endLine,
label,
copyMarkdown,
theme,
threadRef,
onOpen,
onOpenInPanel,
onOpenInBrowser,
className,
}: MarkdownFileLinkProps) {
Expand Down Expand Up @@ -1136,8 +1151,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
handleOpenInEditor();
return;
}
useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line);
}, [handleOpenInEditor, line, threadRef, workspaceRelativePath]);
onOpenInPanel(workspaceRelativePath, line, endLine);
}, [endLine, handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]);

const handleOpenInBrowser = useCallback(() => {
if (!onOpenInBrowser) {
Expand Down Expand Up @@ -1308,11 +1323,13 @@ function areMarkdownFileLinkPropsEqual(
previous.displayPath === next.displayPath &&
previous.workspaceRelativePath === next.workspaceRelativePath &&
previous.line === next.line &&
previous.endLine === next.endLine &&
previous.label === next.label &&
previous.copyMarkdown === next.copyMarkdown &&
previous.theme === next.theme &&
previous.threadRef === next.threadRef &&
previous.onOpen === next.onOpen &&
previous.onOpenInPanel === next.onOpenInPanel &&
previous.onOpenInBrowser === next.onOpenInBrowser &&
previous.className === next.className
);
Expand All @@ -1332,6 +1349,9 @@ function ChatMarkdown({
const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
reportFailure: false,
});
const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {
reportFailure: false,
});
const openPreview = useAtomCommand(previewEnvironment.open, {
reportFailure: false,
});
Expand Down Expand Up @@ -1434,6 +1454,40 @@ function ChatMarkdown({
},
[createAssetUrl, openPreview, preparedConnection, threadRef],
);
// `ChatView.tsx:3301` names a file without saying where it lives, so the link
// resolver can only place it at the workspace root and the read fails there.
// Ask the workspace index for the real location before opening the surface.
const openFileInPanel = useCallback(
(workspaceRelativePath: string, line: number | undefined, endLine: number | undefined) => {
if (!threadRef) return;
const openAt = (path: string) =>
useRightPanelStore.getState().openFile(threadRef, path, line, endLine);
if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) {
openAt(workspaceRelativePath);
return;
}
const isLatestLookup = claimWorkspaceBasenameLookup();
void (async () => {
const result = await searchProjectEntries({
environmentId: threadRef.environmentId,
input: {
cwd,
query: workspaceRelativePath,
limit: WORKSPACE_BASENAME_LOOKUP_LIMIT,
kind: "file",
},
});
const match =
result._tag === "Success"
? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries)
: null;
// A click that landed after this one already owns the panel.
if (!isLatestLookup()) return;
openAt(match ?? workspaceRelativePath);
})();
Comment thread
cursor[bot] marked this conversation as resolved.
},
[cwd, searchProjectEntries, threadRef],
);
/* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component
* renderers that close over this message's metadata. useMemo keeps them stable until that
* metadata changes. */
Expand All @@ -1450,7 +1504,9 @@ function ChatMarkdown({
}
if (fileLinkMeta.line) {
labelParts.push(
`L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
fileLinkMeta.endLine
? `L${fileLinkMeta.line}-${fileLinkMeta.endLine}`
: `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
);
}

Expand All @@ -1462,11 +1518,13 @@ function ChatMarkdown({
displayPath={fileLinkMeta.displayPath}
workspaceRelativePath={fileLinkMeta.workspaceRelativePath}
line={fileLinkMeta.line}
endLine={fileLinkMeta.endLine}
label={labelParts.join(" · ")}
copyMarkdown={copyMarkdown}
theme={resolvedTheme}
threadRef={threadRef}
onOpen={openInPreferredEditor}
onOpenInPanel={openFileInPanel}
onOpenInBrowser={
threadRef &&
isPreviewSupportedInRuntime() &&
Expand Down Expand Up @@ -1685,6 +1743,7 @@ function ChatMarkdown({
isStreaming,
markdownFileLinkMetaByHref,
onTaskListChange,
openFileInPanel,
openInPreferredEditor,
openExternalLinkInPreview,
openMarkdownFileInPreview,
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ import {
collapseExpandedComposerCursor,
parseStandaloneComposerSlashCommand,
} from "../composer-logic";
import { resolveComposerMentionFileTarget } from "../composerMentionFileTarget";
import {
claimWorkspaceBasenameLookup,
needsWorkspaceBasenameLookup,
pickWorkspaceBasenameMatch,
WORKSPACE_BASENAME_LOOKUP_LIMIT,
} from "../workspaceBasenameLookup";
import {
derivePendingApprovals,
derivePendingUserInputs,
Expand Down Expand Up @@ -322,6 +329,7 @@ import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
import { RightPanelSheet } from "./RightPanelSheet";
import { previewEnvironment } from "../state/preview";
import { useAtomCommand } from "../state/use-atom-command";
import { useAtomQueryRunner } from "../state/use-atom-query-runner";
import { Button } from "./ui/button";
import {
AlertDialog,
Expand Down Expand Up @@ -1195,6 +1203,9 @@ function ChatViewContent(props: ChatViewProps) {
);
const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {
reportFailure: false,
});
const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
reportFailure: false,
});
Expand Down Expand Up @@ -3267,6 +3278,43 @@ function ChatViewContent(props: ChatViewProps) {
},
[activeProject, activeThreadRef],
);
// A composer mention chip opens its file the same way a rendered chat file
// link does, so a reference you just wrote is readable before you send it.
// A hand-written mention can name a file without saying where it lives, so
// it goes through the same workspace-index lookup chat links use.
const openMentionFileSurface = useCallback(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
(mentionPath: string) => {
if (!activeThreadRef || !activeProject) return;
const target = resolveComposerMentionFileTarget(mentionPath, activeWorkspaceRoot);
if (!target) return;
const openAt = (path: string) =>
useRightPanelStore.getState().openFile(activeThreadRef, path, target.line, target.endLine);
if (!activeWorkspaceRoot || !needsWorkspaceBasenameLookup(target.relativePath)) {
openAt(target.relativePath);
return;
}
const isLatestLookup = claimWorkspaceBasenameLookup();
void (async () => {
const result = await searchProjectEntries({
environmentId: activeThreadRef.environmentId,
input: {
cwd: activeWorkspaceRoot,
query: target.relativePath,
limit: WORKSPACE_BASENAME_LOOKUP_LIMIT,
kind: "file",
},
});
const match =
result._tag === "Success"
? pickWorkspaceBasenameMatch(target.relativePath, result.value.entries)
: null;
// A click that landed after this one already owns the panel.
if (!isLatestLookup()) return;
openAt(match ?? target.relativePath);
})();
},
[activeProject, activeThreadRef, activeWorkspaceRoot, searchProjectEntries],
);
// The thread's own change request, placed against the project it belongs to. Without a
// project there is nothing to resolve it against, so the caller falls back to the browser.
const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null;
Expand Down Expand Up @@ -6104,6 +6152,7 @@ function ChatViewContent(props: ChatViewProps) {
activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null
}
revealLine={activeFileSurface?.revealLine ?? null}
revealEndLine={activeFileSurface?.revealEndLine ?? null}
revealRequestId={activeFileSurface?.revealRequestId ?? 0}
onOpenFile={openFileSurface}
onPendingChange={handleFilePendingChange}
Expand Down Expand Up @@ -6352,6 +6401,7 @@ function ChatViewContent(props: ChatViewProps) {
keybindings={keybindings}
terminalOpen={Boolean(terminalUiState.terminalOpen)}
gitCwd={gitCwd}
onOpenMentionFile={openMentionFileSurface}
promptRef={promptRef}
composerImagesRef={composerImagesRef}
composerTerminalContextsRef={composerTerminalContextsRef}
Expand Down
Loading
Loading