Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-07-21 21:36:08 +02:00
8 changed files with 131 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { expect, test, type Page } from "./fixtures";
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const RED_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
@@ -31,7 +32,69 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
await expectFileTabOpen(page, filename);
}
async function seedAgentWithFileLink(target: string) {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-editing-chat-link-",
title: "Chat file link e2e",
initialPrompt: [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n"),
});
await writeFile(
path.join(session.cwd, "target.ts"),
Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join(
"\n",
),
"utf8",
);
return session;
}
test.describe("CodeMirror workspace file editing", () => {
test("opens an assistant file link at its referenced line", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink(target);
try {
await openAgentRoute(page, session);
const fileLink = page.getByText(target, { exact: true });
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.click();
await expectFileTabOpen(page, "target.ts");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
await expect(
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
).toBeVisible();
const sourceEditor = editor(page);
await sourceEditor.click();
await sourceEditor.press("Control+Home");
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
await page
.getByTestId(`workspace-tab-agent_${session.agentId}`)
.filter({ visible: true })
.click();
await expect(fileLink).toBeVisible();
await fileLink.click();
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
await expect(
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
).toBeVisible();
} finally {
await session.cleanup();
}
});
test("shows the full file path and keeps editor controls stable", async ({
page,
withWorkspace,

View File

@@ -1,11 +1,14 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HighlightStyle } from "@getpaseo/highlight";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
export function FileEditorView(_props: {
model: FileEditorModel;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
vimEnabled: boolean;
theme: {
background: string;

View File

@@ -3,12 +3,15 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
interface FileEditorViewProps {
model: FileEditorModel;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
vimEnabled: boolean;
theme: EditorVisualTheme;
onCursorChange(position: { line: number; column: number }): void;
@@ -22,6 +25,8 @@ const vimCompartment = new Compartment();
export function FileEditorView({
model,
filename,
location,
navigationRevision,
vimEnabled,
theme,
onCursorChange,
@@ -81,6 +86,19 @@ export function FileEditorView({
});
}, [snapshot.content]);
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
const from = view.state.doc.line(lineStart).from;
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
view.dispatch({
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
}, [location.lineEnd, location.lineStart, navigationRevision]);
useEffect(() => {
viewRef.current?.dispatch({
effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),

View File

@@ -57,6 +57,7 @@ interface FilePreviewBodyProps {
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
}
@@ -207,6 +208,7 @@ function FilePreviewBody({
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
}: FilePreviewBodyProps) {
const theme = UnistylesRuntime.getTheme();
@@ -257,7 +259,7 @@ function FilePreviewBody({
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]);
}, [lineHeight, lineSelection, navigationRevision]);
if (isLoading && !preview) {
return (
@@ -380,10 +382,12 @@ export function FilePane({
serverId,
workspaceRoot,
location,
navigationRevision,
}: {
serverId: string;
workspaceRoot: string;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
@@ -476,6 +480,7 @@ export function FilePane({
isLoading={query.isFetching}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
);
@@ -518,6 +523,7 @@ function FilePanePresentation({
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
}: {
serverId: string;
@@ -535,6 +541,7 @@ function FilePanePresentation({
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
}) {
if (!client && readTarget) {
@@ -562,6 +569,7 @@ function FilePanePresentation({
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
/>
);
}
@@ -587,6 +595,7 @@ function FilePanePresentation({
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
</View>
@@ -605,6 +614,7 @@ function EditableFilePane({
isLoading,
isMobile,
location,
navigationRevision,
}: {
client: DaemonClient;
cwd: string;
@@ -617,6 +627,7 @@ function EditableFilePane({
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { settings } = useAppSettings();
const { t } = useTranslation();
@@ -742,6 +753,8 @@ function EditableFilePane({
<FileEditorView
model={model}
filename={filename}
location={location}
navigationRevision={navigationRevision}
vimEnabled={settings.vimKeybindings}
theme={visualTheme}
onCursorChange={setCursor}
@@ -753,6 +766,7 @@ function EditableFilePane({
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={null}
/>
)}

View File

@@ -30,7 +30,7 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
function FilePanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext();
const { serverId, workspaceId, target, fileNavigationRevision } = usePaneContext();
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
invariant(target.kind === "file", "FilePanel requires file target");
if (!workspaceDirectory) {
@@ -40,7 +40,14 @@ function FilePanel() {
</View>
);
}
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} location={target} />;
return (
<FilePane
serverId={serverId}
workspaceRoot={workspaceDirectory}
location={target}
navigationRevision={fileNavigationRevision ?? 0}
/>
);
}
export const filePanelRegistration: PanelRegistration<"file"> = {

View File

@@ -8,6 +8,7 @@ export interface PaneContextValue {
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
fileNavigationRevision?: number;
openTab: (target: WorkspaceTabTarget) => void;
closeCurrentTab: () => void;
retargetCurrentTab: (target: WorkspaceTabTarget) => void;

View File

@@ -23,6 +23,7 @@ export interface BuildWorkspacePaneContentModelInput {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
fileNavigationRevision?: number;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
onCloseCurrentTab: () => void;
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
@@ -34,6 +35,7 @@ export function buildWorkspacePaneContentModel({
tab,
normalizedServerId,
normalizedWorkspaceId,
fileNavigationRevision,
onOpenTab,
onCloseCurrentTab,
onRetargetCurrentTab,
@@ -51,6 +53,7 @@ export function buildWorkspacePaneContentModel({
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
target: tab.target,
fileNavigationRevision,
openTab: onOpenTab,
closeCurrentTab: onCloseCurrentTab,
retargetCurrentTab: onRetargetCurrentTab,
@@ -85,6 +88,7 @@ export function WorkspacePaneContent({
workspaceId: paneContextValue.workspaceId,
tabId: paneContextValue.tabId,
target: paneContextValue.target,
fileNavigationRevision: paneContextValue.fileNavigationRevision,
openTab,
closeCurrentTab,
retargetCurrentTab,
@@ -97,6 +101,7 @@ export function WorkspacePaneContent({
openImportSheet,
openTab,
paneContextValue.serverId,
paneContextValue.fileNavigationRevision,
paneContextValue.tabId,
paneContextValue.target,
paneContextValue.workspaceId,

View File

@@ -1773,6 +1773,17 @@ function WorkspaceScreenContent({
const openWorkspaceChildTabFocused = useWorkspaceLayoutStore(
(state) => state.openChildTabFocused,
);
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
// requests separate so clicking an unchanged path:line can still recenter the pane.
const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState<
Record<string, number>
>({});
const requestFileNavigation = useCallback((tabId: string) => {
setFileNavigationRevisionByTabId((current) => ({
...current,
[tabId]: (current[tabId] ?? 0) + 1,
}));
}, []);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
@@ -2333,6 +2344,7 @@ function WorkspaceScreenContent({
? openWorkspaceChildTabFocused(persistenceKey, target, options.parentTabId)
: openWorkspaceTabFocused(persistenceKey, target);
if (tabId) {
requestFileNavigation(tabId);
navigateToTabId(tabId);
}
},
@@ -2342,6 +2354,7 @@ function WorkspaceScreenContent({
openWorkspaceChildTabFocused,
openWorkspaceTabFocused,
persistenceKey,
requestFileNavigation,
showMobileAgent,
],
);
@@ -2381,6 +2394,7 @@ function WorkspaceScreenContent({
? openWorkspaceChildTabFocused(persistenceKey, target, input.parentTabId)
: openWorkspaceTabFocused(persistenceKey, target);
if (tabId) {
requestFileNavigation(tabId);
navigateToTabId(tabId);
}
},
@@ -2392,6 +2406,7 @@ function WorkspaceScreenContent({
openWorkspaceChildTabFocused,
openWorkspaceTabFocused,
persistenceKey,
requestFileNavigation,
splitWorkspacePaneEmpty,
uiTabs,
workspaceLayout,
@@ -3211,6 +3226,7 @@ function WorkspaceScreenContent({
tab: input.tab,
normalizedServerId,
normalizedWorkspaceId,
fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0,
onOpenTab: (target) => {
if (!persistenceKey) {
return;
@@ -3245,6 +3261,7 @@ function WorkspaceScreenContent({
[
handleCloseTabById,
focusWorkspacePane,
fileNavigationRevisionByTabId,
handleOpenWorkspaceFileFromPane,
navigateToTabId,
normalizedServerId,