mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? []),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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"> = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user