From ee44b536f5ce947f61760666bf1bb4452fc7cd7c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 17 May 2026 22:55:46 +0800 Subject: [PATCH] Support line ranges in assistant file links (#1067) --- .../app/src/assistant-file-links/index.ts | 13 +++ .../link.tsx} | 6 +- .../parse.test.ts} | 38 +++++++- .../parse.ts} | 12 ++- .../resolver.test.ts} | 27 +++++- .../resolver.ts} | 4 +- .../use-resolver.ts} | 6 +- .../app/src/components/agent-stream-view.tsx | 35 ++++--- packages/app/src/components/file-pane.tsx | 92 ++++++++++++++++--- packages/app/src/components/message.tsx | 19 ++-- .../app/src/components/split-container.tsx | 2 +- packages/app/src/panels/agent-panel.tsx | 19 ++-- packages/app/src/panels/draft-panel.tsx | 10 +- packages/app/src/panels/file-panel.tsx | 2 +- packages/app/src/panels/pane-context.tsx | 4 +- .../workspace/workspace-draft-agent-tab.tsx | 4 +- .../workspace/workspace-pane-content.tsx | 4 +- .../workspace/workspace-pane-state.test.ts | 24 +++++ .../screens/workspace/workspace-pane-state.ts | 7 +- .../screens/workspace/workspace-screen.tsx | 59 ++++++++---- .../screens/workspace/workspace-tab-model.ts | 2 +- .../src/stores/workspace-layout-actions.ts | 47 ++++++++-- .../src/stores/workspace-layout-store.test.ts | 33 +++++++ .../app/src/stores/workspace-layout-store.ts | 2 +- .../app/src/stores/workspace-tabs-store.ts | 12 ++- packages/app/src/utils/workspace-file-open.ts | 1 - .../identity.ts} | 13 ++- .../app/src/workspace/file-open/index.test.ts | 63 +++++++++++++ packages/app/src/workspace/file-open/index.ts | 59 ++++++++++++ 29 files changed, 504 insertions(+), 115 deletions(-) create mode 100644 packages/app/src/assistant-file-links/index.ts rename packages/app/src/{components/assistant-file-link.tsx => assistant-file-links/link.tsx} (98%) rename packages/app/src/{utils/inline-path.test.ts => assistant-file-links/parse.test.ts} (90%) rename packages/app/src/{utils/inline-path.ts => assistant-file-links/parse.ts} (96%) rename packages/app/src/{utils/assistant-file-link-resolver.test.ts => assistant-file-links/resolver.test.ts} (95%) rename packages/app/src/{utils/assistant-file-link-resolver.ts => assistant-file-links/resolver.ts} (98%) rename packages/app/src/{hooks/use-assistant-file-link-resolver.ts => assistant-file-links/use-resolver.ts} (93%) delete mode 100644 packages/app/src/utils/workspace-file-open.ts rename packages/app/src/{utils/workspace-tab-identity.ts => workspace-tabs/identity.ts} (91%) create mode 100644 packages/app/src/workspace/file-open/index.test.ts create mode 100644 packages/app/src/workspace/file-open/index.ts diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts new file mode 100644 index 000000000..7b1a93aeb --- /dev/null +++ b/packages/app/src/assistant-file-links/index.ts @@ -0,0 +1,13 @@ +export { + AssistantInlineCodePathLink, + AssistantInlinePathLink, + AssistantMarkdownCodeLink, + AssistantMarkdownLink, +} from "./link"; +export { + classifyAssistantFileLink, + normalizeInlinePathTarget, + type InlinePathTarget, +} from "./parse"; +export type { AssistantFileLinkSource } from "./resolver"; +export { useAssistantFileLinkResolver } from "./use-resolver"; diff --git a/packages/app/src/components/assistant-file-link.tsx b/packages/app/src/assistant-file-links/link.tsx similarity index 98% rename from packages/app/src/components/assistant-file-link.tsx rename to packages/app/src/assistant-file-links/link.tsx index c70666923..046ba15ea 100644 --- a/packages/app/src/components/assistant-file-link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -16,11 +16,11 @@ import { } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { isNative, isWeb } from "@/constants/platform"; -import { classifyAssistantFileLink, type InlinePathTarget } from "@/utils/inline-path"; -import type { AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver"; -import type { OpenFileDisposition } from "@/utils/workspace-file-open"; +import type { OpenFileDisposition } from "@/workspace/file-open"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { classifyAssistantFileLink, type InlinePathTarget } from "./parse"; +import type { AssistantFileLinkSource } from "./resolver"; interface AssistantInlinePathLinkProps { content: string; diff --git a/packages/app/src/utils/inline-path.test.ts b/packages/app/src/assistant-file-links/parse.test.ts similarity index 90% rename from packages/app/src/utils/inline-path.test.ts rename to packages/app/src/assistant-file-links/parse.test.ts index 866bece0f..aa7663db0 100644 --- a/packages/app/src/utils/inline-path.test.ts +++ b/packages/app/src/assistant-file-links/parse.test.ts @@ -5,7 +5,7 @@ import { parseAssistantFileLink, parseFileProtocolUrl, parseInlinePathToken, -} from "./inline-path"; +} from "./parse"; describe("parseInlinePathToken", () => { it("returns null for plain paths without a line number", () => { @@ -31,6 +31,33 @@ describe("parseInlinePathToken", () => { }); }); + it("parses filename:line:column as a line target", () => { + expect(parseInlinePathToken("src/app.ts:12:4")).toEqual({ + raw: "src/app.ts:12:4", + path: "src/app.ts", + lineStart: 12, + lineEnd: undefined, + }); + }); + + it("parses filename(line,column) as a line target", () => { + expect(parseInlinePathToken("src/app.ts(12,4)")).toEqual({ + raw: "src/app.ts(12,4)", + path: "src/app.ts", + lineStart: 12, + lineEnd: undefined, + }); + }); + + it("parses filename lines lineStart-lineEnd", () => { + expect(parseInlinePathToken("src/app.ts lines 12-20")).toEqual({ + raw: "src/app.ts lines 12-20", + path: "src/app.ts", + lineStart: 12, + lineEnd: 20, + }); + }); + it("rejects range-only :line tokens", () => { expect(parseInlinePathToken(":12")).toBeNull(); expect(parseInlinePathToken(":12-20")).toBeNull(); @@ -47,6 +74,15 @@ describe("parseFileProtocolUrl", () => { }); }); + it("parses file URLs with line-column fragments", () => { + expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81C5-L83C2")).toEqual({ + raw: "file:///Users/test/project/src/app.tsx#L81C5-L83C2", + path: "/Users/test/project/src/app.tsx", + lineStart: 81, + lineEnd: 83, + }); + }); + it("parses file URLs without line fragments", () => { expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({ raw: "file:///Users/test/project/src/app.tsx", diff --git a/packages/app/src/utils/inline-path.ts b/packages/app/src/assistant-file-links/parse.ts similarity index 96% rename from packages/app/src/utils/inline-path.ts rename to packages/app/src/assistant-file-links/parse.ts index c32c5bc3e..0f1d0351c 100644 --- a/packages/app/src/utils/inline-path.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -1,4 +1,4 @@ -import { isAbsolutePath } from "./path"; +import { isAbsolutePath } from "@/utils/path"; export interface InlinePathTarget { raw: string; @@ -8,7 +8,10 @@ export interface InlinePathTarget { } const FILE_PROTOCOL = "file:"; -const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i; +const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:C[0-9]+)?(?:-L?([0-9]+)(?:C[0-9]+)?)?$/i; +const INLINE_COLON_LINE_SUFFIX = /^(.+?):([0-9]+)(?::[0-9]+)?(?:-([0-9]+)(?::[0-9]+)?)?$/; +const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0-9]+)?)?\)$/; +const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i; const ASSISTANT_FILE_EXTENSIONS = new Set([ "astro", "bash", @@ -142,7 +145,10 @@ export function parseInlinePathToken(value: string): InlinePathTarget | null { return null; } - const match = trimmed.match(/^(.+?):([0-9]+)(?:-([0-9]+))?$/); + const match = + trimmed.match(INLINE_COLON_LINE_SUFFIX) ?? + trimmed.match(INLINE_PAREN_LINE_SUFFIX) ?? + trimmed.match(INLINE_WORD_LINE_SUFFIX); if (!match) { return null; } diff --git a/packages/app/src/utils/assistant-file-link-resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts similarity index 95% rename from packages/app/src/utils/assistant-file-link-resolver.test.ts rename to packages/app/src/assistant-file-links/resolver.test.ts index 537f36142..82a4efc80 100644 --- a/packages/app/src/utils/assistant-file-link-resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -4,7 +4,7 @@ import { getAssistantFileLinkToken, type AssistantFileLinkContext, type DirectorySuggestionResult, -} from "./assistant-file-link-resolver"; +} from "./resolver"; const CONTEXT: AssistantFileLinkContext = { serverId: "server-1", @@ -259,6 +259,31 @@ describe("assistant file link resolver", () => { expect(result.opened).toBe(true); }); + it("preserves direct workspace file line ranges", async () => { + const openWorkspaceFile = vi.fn(); + const resolver = createAssistantFileLinkResolver({ + getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), + openWorkspaceFile, + openExternalUrl: vi.fn(), + }); + + await resolver.open({ + context: CONTEXT, + source: { href: "src/components/message.tsx:33-40" }, + disposition: "main", + }); + + expect(openWorkspaceFile).toHaveBeenCalledWith( + { + raw: "src/components/message.tsx:33-40", + path: "/Users/test/project/src/components/message.tsx", + lineStart: 33, + lineEnd: 40, + }, + "main", + ); + }); + it("resolves inline-code basename line refs through directory suggestions", async () => { const suggestions = vi.fn(async () => resolvedSuggestions([ diff --git a/packages/app/src/utils/assistant-file-link-resolver.ts b/packages/app/src/assistant-file-links/resolver.ts similarity index 98% rename from packages/app/src/utils/assistant-file-link-resolver.ts rename to packages/app/src/assistant-file-links/resolver.ts index 91c1f07d7..e9c32cd84 100644 --- a/packages/app/src/utils/assistant-file-link-resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -3,8 +3,8 @@ import { isFileLookingAssistantToken, type AssistantFileLinkClassification, type InlinePathTarget, -} from "./inline-path"; -import type { OpenFileDisposition } from "./workspace-file-open"; +} from "./parse"; +import type { OpenFileDisposition } from "@/workspace/file-open"; export interface AssistantFileLinkContext { serverId?: string; diff --git a/packages/app/src/hooks/use-assistant-file-link-resolver.ts b/packages/app/src/assistant-file-links/use-resolver.ts similarity index 93% rename from packages/app/src/hooks/use-assistant-file-link-resolver.ts rename to packages/app/src/assistant-file-links/use-resolver.ts index 48071fad6..c3facded4 100644 --- a/packages/app/src/hooks/use-assistant-file-link-resolver.ts +++ b/packages/app/src/assistant-file-links/use-resolver.ts @@ -6,9 +6,9 @@ import { type AssistantFileLinkContext, type AssistantFileLinkOpenInput, type AssistantFileLinkPrefetchInput, -} from "@/utils/assistant-file-link-resolver"; -import type { InlinePathTarget } from "@/utils/inline-path"; -import type { OpenFileDisposition } from "@/utils/workspace-file-open"; +} from "./resolver"; +import type { InlinePathTarget } from "./parse"; +import type { OpenFileDisposition } from "@/workspace/file-open"; import { openExternalUrl } from "@/utils/open-external-url"; export interface UseAssistantFileLinkResolverOptions { diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 11895cfa6..2826f8a8c 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -74,11 +74,15 @@ import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./use-bottom-anchor-controller"; -import { normalizeInlinePathTarget } from "@/utils/inline-path"; -import type { OpenFileDisposition } from "@/utils/workspace-file-open"; +import { normalizeInlinePathTarget } from "@/assistant-file-links"; +import { + createWorkspaceFileTabTarget, + normalizeWorkspaceFileLocation, + type OpenFileDisposition, + type WorkspaceFileOpenRequest, +} from "@/workspace/file-open"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; -import { useStableEvent } from "@/hooks/use-stable-event"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; @@ -243,7 +247,7 @@ export interface AgentStreamViewProps { routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; toast?: ToastApi | null; - onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; } const EMPTY_STREAM_HEAD: StreamItem[] = []; @@ -303,12 +307,6 @@ const AgentStreamViewComponent = forwardRef 0 ? trimmed : null; } +interface FileLineSelection { + lineStart: number; + lineEnd: number; +} + function formatFileSize({ size }: { size: number }): string { if (size < 1024) { return `${size} B`; @@ -92,14 +99,38 @@ async function createFilePanePreview(file: FileReadResult | null): Promise<{ }; } -const CodeLine = React.memo(function CodeLine({ tokens, lineNumber, gutterWidth }: CodeLineProps) { +function clampLineSelection(input: { + lineStart?: number; + lineEnd?: number; + lineCount: number; +}): FileLineSelection | null { + if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) { + return null; + } + const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount); + const rawLineEnd = + input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart; + const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount); + return { lineStart, lineEnd: Math.max(lineStart, lineEnd) }; +} + +const CodeLine = React.memo(function CodeLine({ + tokens, + lineNumber, + gutterWidth, + highlighted, +}: CodeLineProps) { const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]); + const lineStyle = useMemo( + () => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine], + [highlighted], + ); const keyedTokens = useMemo( () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), [tokens], ); return ( - + {String(lineNumber)} @@ -126,6 +157,9 @@ const codeLineStyles = StyleSheet.create((theme) => ({ line: { flexDirection: "row", }, + highlightedLine: { + backgroundColor: theme.colors.accentBorder, + }, gutter: { alignItems: "flex-end", paddingRight: theme.spacing[3], @@ -152,13 +186,15 @@ function FilePreviewBody({ isLoading, showDesktopWebScrollbar, isMobile, - filePath, + location, imagePreviewUri, }: FilePreviewBodyProps) { const { theme } = useUnistyles(); + const filePath = location.path; const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); - const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath); + const isMarkdownFile = + preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart; const previewScrollRef = useRef(null); const webScrollbarStyle = useWebScrollbarStyle(); @@ -178,12 +214,36 @@ function FilePreviewBody({ if (!highlightedLines) return 0; return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm); }, [highlightedLines, theme.fontSize.sm]); + const lineHeight = theme.fontSize.sm * 1.45; + const lineSelection = useMemo(() => { + if (!highlightedLines) { + return null; + } + return clampLineSelection({ + lineStart: location.lineStart, + lineEnd: location.lineEnd, + lineCount: highlightedLines.length, + }); + }, [highlightedLines, location.lineEnd, location.lineStart]); const imageSource = useMemo( () => (imagePreviewUri ? { uri: imagePreviewUri } : null), [imagePreviewUri], ); + useEffect(() => { + if (!lineSelection) { + return; + } + const timeout = setTimeout(() => { + previewScrollRef.current?.scrollTo({ + y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight), + animated: false, + }); + }, 0); + return () => clearTimeout(timeout); + }, [lineHeight, lineSelection]); + if (isLoading && !preview) { return ( @@ -233,7 +293,17 @@ function FilePreviewBody({ const codeLines = ( {keyedLines.map(({ key, tokens, lineNumber }) => ( - + = (lineSelection?.lineStart ?? 0) && + lineNumber <= (lineSelection?.lineEnd ?? 0) + } + /> ))} ); @@ -312,18 +382,18 @@ function FilePreviewBody({ export function FilePane({ serverId, workspaceRoot, - filePath, + location, }: { serverId: string; workspaceRoot: string; - filePath: string; + location: WorkspaceFileLocation; }) { const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = isWeb && !isMobile; const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); - const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]); + const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]); const query = useQuery({ queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath], @@ -366,7 +436,7 @@ export function FilePane({ isLoading={query.isFetching} showDesktopWebScrollbar={showDesktopWebScrollbar} isMobile={isMobile} - filePath={filePath} + location={location} imagePreviewUri={imagePreviewUri} /> diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index cd00e0672..18015ad60 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -68,11 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; -import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; -import type { OpenFileDisposition } from "@/utils/workspace-file-open"; +import type { OpenFileDisposition } from "@/workspace/file-open"; import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list"; -import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver"; -import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver"; import type { ToastApi } from "@/components/toast-host"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; @@ -95,15 +92,19 @@ import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { AssistantInlineCodePathLink, + classifyAssistantFileLink, + type AssistantFileLinkSource, AssistantMarkdownCodeLink, AssistantMarkdownLink, -} from "./assistant-file-link"; + type InlinePathTarget, + useAssistantFileLinkResolver, +} from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; import type { DaemonClient } from "@server/client/daemon-client"; import { isWeb, isNative } from "@/constants/platform"; -export type { InlinePathTarget } from "@/utils/inline-path"; +export type { InlinePathTarget } from "@/assistant-file-links"; type MarkdownStyles = Record; @@ -1683,8 +1684,12 @@ export const AssistantMessage = memo(function AssistantMessage({ ) => { const content = node.content ?? ""; const isLinkedInlineCode = nodeHasParentType(parent, "link"); + const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot }); const shouldResolveInlinePath = - onInlinePathPress && !isLinkedInlineCode && parseInlinePathToken(content); + onInlinePathPress && + !isLinkedInlineCode && + inlineCodeFileLink && + inlineCodeFileLink.kind !== "external"; if (shouldResolveInlinePath) { return ( diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index eb1794046..00f999f0b 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -67,7 +67,7 @@ import { type WorkspaceLayout, } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; -import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity"; +import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { isNative } from "@/constants/platform"; interface SplitContainerProps { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index bdcfaf7b2..6798f471e 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -32,7 +32,6 @@ import { } from "@/hooks/use-agent-screen-state-machine"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useStableEvent } from "@/hooks/use-stable-event"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { @@ -59,7 +58,7 @@ import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import type { OpenFileDisposition } from "@/utils/workspace-file-open"; +import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; @@ -289,18 +288,12 @@ function AgentPanel() { const { isInteractive } = usePaneFocus(); invariant(target.kind === "agent", "AgentPanel requires agent target"); - const handleOpenWorkspaceFile = useStableEvent( - ({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => { - openFileInWorkspace(filePath, disposition); - }, - ); - return ( ); } @@ -366,7 +359,7 @@ function AgentPanelContent({ serverId: string; agentId: string; isPaneFocused: boolean; - onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const resolvedAgentId = agentId.trim() || undefined; const resolvedServerId = serverId.trim() || undefined; @@ -428,7 +421,7 @@ function AgentPanelBody({ client: NonNullable>; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; - onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); @@ -593,7 +586,7 @@ function ChatAgentContent({ client: NonNullable>; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; - onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const panelToast = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); @@ -1042,7 +1035,7 @@ function AgentStreamSection({ routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; toast: ReturnType["api"]; - onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const streamItemsRaw = useSessionStore((state) => agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx index b1dcfa97a..7b666190c 100644 --- a/packages/app/src/panels/draft-panel.tsx +++ b/packages/app/src/panels/draft-panel.tsx @@ -6,7 +6,6 @@ import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelRegistration } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import type { OpenFileDisposition } from "@/utils/workspace-file-open"; function useDraftPanelDescriptor() { return { @@ -31,13 +30,6 @@ function DraftPanel() { const { isInteractive } = usePaneFocus(); invariant(target.kind === "draft", "DraftPanel requires draft target"); - const handleOpenWorkspaceFile = useCallback( - ({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => { - openFileInWorkspace(filePath, disposition); - }, - [openFileInWorkspace], - ); - const handleCreated = useCallback( (agentSnapshot: Parameters[0]) => { const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); @@ -59,7 +51,7 @@ function DraftPanel() { draftId={target.draftId} initialSetup={target.setup} isPaneFocused={isInteractive} - onOpenWorkspaceFile={handleOpenWorkspaceFile} + onOpenWorkspaceFile={openFileInWorkspace} onCreated={handleCreated} onOpenImportSheet={openImportSheet} /> diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index a9bcf9d71..303bfff72 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -38,7 +38,7 @@ function FilePanel() { ); } - return ; + return ; } export const filePanelRegistration: PanelRegistration<"file"> = { diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 389dc2363..6ec608db3 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, type ReactNode } from "react"; import invariant from "tiny-invariant"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; -import type { OpenFileDisposition } from "@/utils/workspace-file-open"; +import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; export interface PaneContextValue { serverId: string; @@ -11,7 +11,7 @@ export interface PaneContextValue { openTab: (target: WorkspaceTabTarget) => void; closeCurrentTab: () => void; retargetCurrentTab: (target: WorkspaceTabTarget) => void; - openFileInWorkspace: (filePath: string, disposition: OpenFileDisposition) => void; + openFileInWorkspace: (request: WorkspaceFileOpenRequest) => void; openImportSheet: () => void; } diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 087d3e241..318ffefa2 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -20,7 +20,7 @@ import type { Agent } from "@/stores/session-store"; import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { encodeImages } from "@/utils/encode-images"; -import type { OpenFileDisposition } from "@/utils/workspace-file-open"; +import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types"; import type { AgentSnapshotPayload } from "@server/shared/messages"; @@ -319,7 +319,7 @@ interface WorkspaceDraftAgentTabProps { initialSetup?: WorkspaceDraftTabSetup; isPaneFocused: boolean; onCreated: (snapshot: AgentSnapshotPayload) => void; - onOpenWorkspaceFile: (input: { filePath: string; disposition: OpenFileDisposition }) => void; + onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void; onOpenImportSheet?: () => void; } diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index c5a4a233f..27fe2ba6c 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -9,7 +9,7 @@ import { import { getPanelRegistration } from "@/panels/panel-registry"; import { ensurePanelsRegistered } from "@/panels/register-panels"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import type { OpenFileDisposition } from "@/utils/workspace-file-open"; +import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; export interface WorkspacePaneContentModel { key: string; @@ -24,7 +24,7 @@ export interface BuildWorkspacePaneContentModelInput { onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onCloseCurrentTab: () => void; onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void; - onOpenWorkspaceFile: (filePath: string, disposition: OpenFileDisposition) => void; + onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void; onOpenImportSheet: () => void; } diff --git a/packages/app/src/screens/workspace/workspace-pane-state.test.ts b/packages/app/src/screens/workspace/workspace-pane-state.test.ts index eb36f3b78..d57e4ffac 100644 --- a/packages/app/src/screens/workspace/workspace-pane-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-pane-state.test.ts @@ -130,6 +130,30 @@ describe("workspace-pane-state", () => { ).toEqual({ kind: "open-in-source" }); }); + it("resolves side file opens to an existing file tab when only the line range differs", () => { + const layout: WorkspaceLayout = { + root: { + kind: "pane", + pane: { + id: "main", + tabIds: ["file_/repo/README.md"], + focusedTabId: "file_/repo/README.md", + }, + }, + focusedPaneId: "main", + }; + const tabs = [createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" })]; + + expect( + resolveSideFileOpenPlacement({ + layout, + sourcePaneId: "main", + tabs, + target: { kind: "file", path: "/repo/README.md", lineStart: 12, lineEnd: 20 }, + }), + ).toEqual({ kind: "open-in-source" }); + }); + it("resolves side file opens to an existing right pane", () => { const layout: WorkspaceLayout = { root: { diff --git a/packages/app/src/screens/workspace/workspace-pane-state.ts b/packages/app/src/screens/workspace/workspace-pane-state.ts index c5beb1fa2..e32a05722 100644 --- a/packages/app/src/screens/workspace/workspace-pane-state.ts +++ b/packages/app/src/screens/workspace/workspace-pane-state.ts @@ -9,7 +9,7 @@ import { buildDeterministicWorkspaceTabId, normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, -} from "@/utils/workspace-tab-identity"; +} from "@/workspace-tabs/identity"; import { findAdjacentPane } from "@/utils/split-navigation"; export interface WorkspaceDerivedTab { @@ -209,7 +209,10 @@ export function resolveSideFileOpenPlacement(input: { tabs: WorkspaceTab[]; target: WorkspaceTabTarget; }): WorkspaceSideFileOpenPlacement { - const existingTab = input.tabs.find((tab) => workspaceTabTargetsEqual(tab.target, input.target)); + const targetTabId = buildDeterministicWorkspaceTabId(input.target); + const existingTab = input.tabs.find( + (tab) => tab.tabId === targetTabId || workspaceTabTargetsEqual(tab.target, input.target), + ); if (existingTab) { return { kind: "open-in-source" }; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 09ff6d004..dff7355ff 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -77,10 +77,7 @@ import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-s import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { - normalizeWorkspaceTabTarget, - workspaceTabTargetsEqual, -} from "@/utils/workspace-tab-identity"; +import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { getHostRuntimeStore, useHostRuntimeClient, @@ -161,6 +158,12 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state"; import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; +import { + createWorkspaceFileTabTarget, + normalizeWorkspaceFileLocation, + type WorkspaceFileLocation, + type WorkspaceFileOpenRequest, +} from "@/workspace/file-open"; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const EMPTY_UI_TABS: WorkspaceTab[] = []; @@ -2023,7 +2026,11 @@ function WorkspaceScreenContent({ if (!persistenceKey) { return; } - const tabId = openWorkspaceTabFocused(persistenceKey, { kind: "file", path: filePath }); + const location = normalizeWorkspaceFileLocation({ path: filePath }); + if (!location) { + return; + } + const tabId = openWorkspaceTabFocused(persistenceKey, createWorkspaceFileTabTarget(location)); if (tabId) { navigateToTabId(tabId); } @@ -2032,28 +2039,40 @@ function WorkspaceScreenContent({ ); const handleOpenFileFromChat = useCallback( - (filePath: string) => { - const normalizedFilePath = filePath.trim(); - if (!normalizedFilePath) { + (location: WorkspaceFileLocation) => { + const normalizedLocation = normalizeWorkspaceFileLocation(location); + if (!normalizedLocation) { return; } - handleOpenFileFromExplorer(normalizedFilePath); + if (isMobile) { + showMobileAgent(); + } + if (!persistenceKey) { + return; + } + const tabId = openWorkspaceTabFocused( + persistenceKey, + createWorkspaceFileTabTarget(normalizedLocation), + ); + if (tabId) { + navigateToTabId(tabId); + } }, - [handleOpenFileFromExplorer], + [isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent], ); const handleOpenFileFromChatInSidePane = useCallback( - (input: { filePath: string; sourcePaneId?: string }) => { - const normalizedFilePath = input.filePath.trim(); - if (!normalizedFilePath) { + (input: { location: WorkspaceFileLocation; sourcePaneId?: string }) => { + const location = normalizeWorkspaceFileLocation(input.location); + if (!location) { return; } if (!persistenceKey || isMobile || !input.sourcePaneId) { - handleOpenFileFromExplorer(normalizedFilePath); + handleOpenFileFromChat(location); return; } - const target: WorkspaceTabTarget = { kind: "file", path: normalizedFilePath }; + const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location); const placement = resolveSideFileOpenPlacement({ layout: workspaceLayout, sourcePaneId: input.sourcePaneId, @@ -2075,7 +2094,7 @@ function WorkspaceScreenContent({ } }, [ - handleOpenFileFromExplorer, + handleOpenFileFromChat, isMobile, focusWorkspacePane, navigateToTabId, @@ -2755,18 +2774,18 @@ function WorkspaceScreenContent({ } retargetWorkspaceTab(persistenceKey, input.tab.tabId, target); }, - onOpenWorkspaceFile: (filePath, disposition) => { + onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => { if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) { focusWorkspacePane(persistenceKey, input.paneId); } - if (disposition === "side") { + if (request.disposition === "side") { handleOpenFileFromChatInSidePane({ - filePath, + location: request.location, sourcePaneId: input.paneId ?? undefined, }); return; } - handleOpenFileFromChat(filePath); + handleOpenFileFromChat(request.location); }, onOpenImportSheet: openImportSheet, }), diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index 35b60b9ae..6a5c8acfa 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -3,7 +3,7 @@ import { deriveWorkspacePaneState, type WorkspaceDerivedTab, } from "@/screens/workspace/workspace-pane-state"; -import { buildDeterministicWorkspaceTabId } from "@/utils/workspace-tab-identity"; +import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity"; export interface WorkspaceTabModel { tabs: WorkspaceDerivedTab[]; diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 630cc5220..3e4483057 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -6,7 +6,7 @@ import { buildDeterministicWorkspaceTabId, normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, -} from "@/utils/workspace-tab-identity"; +} from "@/workspace-tabs/identity"; export interface SplitPane { id: string; @@ -1051,19 +1051,45 @@ function insertNewTabIntoFocusedPane(input: { }; } +function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) { + const targetTabId = buildDeterministicWorkspaceTabId(target); + return ( + collectAllTabs(root).find( + (tab) => tab.tabId === targetTabId || workspaceTabTargetsEqual(tab.target, target), + ) ?? null + ); +} + +function updateExistingTabTarget( + layout: { root: SplitNodeInternal; focusedPaneId: string | null }, + tab: WorkspaceTab, + target: WorkspaceTabTarget, +): { root: SplitNodeInternal; focusedPaneId: string | null } { + if (workspaceTabTargetsEqual(tab.target, target)) { + return layout; + } + return { + ...layout, + root: replaceTabInTree(layout.root, { + tabId: tab.tabId, + nextTabId: tab.tabId, + target, + }), + }; +} + export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLayoutResult { const layout = asInternalLayout(input.layout); - const existingTab = collectAllTabs(layout.root).find((tab) => - workspaceTabTargetsEqual(tab.target, input.target), - ); + const existingTab = findExistingTabForTarget(layout.root, input.target); if (existingTab) { + const nextLayout = updateExistingTabTarget(layout, existingTab, input.target); return { tabId: existingTab.tabId, layout: focusTabInLayout({ - layout, + layout: nextLayout, tabId: existingTab.tabId, - }) ?? input.layout, + }) ?? nextLayout, }; } @@ -1072,11 +1098,12 @@ export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLa export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult { const layout = asInternalLayout(input.layout); - const existingTab = collectAllTabs(layout.root).find((tab) => - workspaceTabTargetsEqual(tab.target, input.target), - ); + const existingTab = findExistingTabForTarget(layout.root, input.target); if (existingTab) { - return { tabId: existingTab.tabId, layout: input.layout }; + return { + tabId: existingTab.tabId, + layout: updateExistingTabTarget(layout, existingTab, input.target), + }; } return insertNewTabIntoFocusedPane({ ...input, focus: false }); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index ded1ce2a8..318e786b0 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -353,6 +353,39 @@ describe("workspace-layout-store actions", () => { ]); }); + it("updates an existing file tab when opening the same path at a new line range", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + + const firstTabId = store.openTabFocused(workspaceKey, { + kind: "file", + path: "/repo/worktree/a.ts", + lineStart: 5, + }); + const secondTabId = store.openTabFocused(workspaceKey, { + kind: "file", + path: "/repo/worktree/a.ts", + lineStart: 10, + lineEnd: 12, + }); + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + + expect(firstTabId).toBe("file_/repo/worktree/a.ts"); + expect(secondTabId).toBe(firstTabId); + expect(collectAllTabs(layout.root)).toEqual([ + { + tabId: "file_/repo/worktree/a.ts", + target: { + kind: "file", + path: "/repo/worktree/a.ts", + lineStart: 10, + lineEnd: 12, + }, + createdAt: expect.any(Number), + }, + ]); + }); + it("openTabInBackground inserts a tab without stealing focus", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 5e15be37d..5dd90bca7 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -44,7 +44,7 @@ import { type WorkspaceTabSnapshot, type WorkspaceLayout, } from "@/stores/workspace-layout-actions"; -import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity"; +import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; export { buildWorkspaceTabPersistenceKey }; export { diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index 84a38ce58..da88dc800 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -7,7 +7,8 @@ import { normalizeWorkspaceDraftTabSetup, normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, -} from "@/utils/workspace-tab-identity"; +} from "@/workspace-tabs/identity"; +import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; export interface WorkspaceDraftTabSetup { provider: AgentProvider; @@ -23,7 +24,7 @@ export type WorkspaceTabTarget = | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } | { kind: "browser"; browserId: string } - | { kind: "file"; path: string } + | WorkspaceFileTabTarget | { kind: "setup"; workspaceId: string }; export interface WorkspaceTab { @@ -168,7 +169,12 @@ function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTar return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId }); } if (kind === "file" && typeof raw.path === "string") { - return normalizeWorkspaceTabTarget({ kind: "file", path: raw.path }); + return normalizeWorkspaceTabTarget({ + kind: "file", + path: raw.path, + lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined, + lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined, + }); } if (kind === "setup" && typeof raw.workspaceId === "string") { return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); diff --git a/packages/app/src/utils/workspace-file-open.ts b/packages/app/src/utils/workspace-file-open.ts deleted file mode 100644 index 6dfd8a5d8..000000000 --- a/packages/app/src/utils/workspace-file-open.ts +++ /dev/null @@ -1 +0,0 @@ -export type OpenFileDisposition = "main" | "side"; diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/workspace-tabs/identity.ts similarity index 91% rename from packages/app/src/utils/workspace-tab-identity.ts rename to packages/app/src/workspace-tabs/identity.ts index fd08cb34e..35a2693e8 100644 --- a/packages/app/src/utils/workspace-tab-identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -1,4 +1,5 @@ import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open"; type WorkspaceDraftTabSetup = NonNullable["setup"]>; @@ -29,8 +30,7 @@ export function normalizeWorkspaceTabTarget( return browserId ? { kind: "browser", browserId } : null; } if (value.kind === "file") { - const path = trimNonEmpty(value.path); - return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; + return normalizeFileTabTarget(value); } if (value.kind === "setup") { const workspaceId = trimNonEmpty(value.workspaceId); @@ -83,7 +83,7 @@ export function workspaceTabTargetsEqual( return left.browserId === right.browserId; } if (left.kind === "file" && right.kind === "file") { - return left.path === right.path; + return workspaceFileLocationsEqual(left, right); } if (left.kind === "setup" && right.kind === "setup") { return left.workspaceId === right.workspaceId; @@ -151,6 +151,13 @@ function trimNonEmpty(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeFileTabTarget( + value: Extract, +): WorkspaceTabTarget | null { + const location = normalizeWorkspaceFileLocation(value); + return location ? { kind: "file", ...location } : null; +} + function trimOptionalString(value: string | null | undefined): string | null { return value == null ? null : trimNonEmpty(value); } diff --git a/packages/app/src/workspace/file-open/index.test.ts b/packages/app/src/workspace/file-open/index.test.ts new file mode 100644 index 000000000..a54d791f4 --- /dev/null +++ b/packages/app/src/workspace/file-open/index.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + createWorkspaceFileTabTarget, + normalizeWorkspaceFileLocation, + workspaceFileLocationsEqual, +} from "."; + +describe("normalizeWorkspaceFileLocation", () => { + it("normalizes paths and valid line ranges", () => { + expect( + normalizeWorkspaceFileLocation({ + path: "src\\app.ts", + lineStart: 12.8, + lineEnd: 20.2, + }), + ).toEqual({ + path: "src/app.ts", + lineStart: 12, + lineEnd: 20, + }); + }); + + it("drops invalid or backwards line ranges", () => { + expect(normalizeWorkspaceFileLocation({ path: "src/app.ts", lineStart: -1 })).toEqual({ + path: "src/app.ts", + }); + expect( + normalizeWorkspaceFileLocation({ path: "src/app.ts", lineStart: 20, lineEnd: 12 }), + ).toEqual({ + path: "src/app.ts", + lineStart: 20, + }); + }); + + it("rejects empty paths", () => { + expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull(); + }); +}); + +describe("workspace file tab targets", () => { + it("keeps file tab identity separate from line selection", () => { + expect(createWorkspaceFileTabTarget({ path: "src/app.ts", lineStart: 12 })).toEqual({ + kind: "file", + path: "src/app.ts", + lineStart: 12, + }); + }); + + it("compares full location equality", () => { + expect( + workspaceFileLocationsEqual( + { path: "src/app.ts", lineStart: 12 }, + { path: "src/app.ts", lineStart: 12 }, + ), + ).toBe(true); + expect( + workspaceFileLocationsEqual( + { path: "src/app.ts", lineStart: 12 }, + { path: "src/app.ts", lineStart: 13 }, + ), + ).toBe(false); + }); +}); diff --git a/packages/app/src/workspace/file-open/index.ts b/packages/app/src/workspace/file-open/index.ts new file mode 100644 index 000000000..c79c77fa0 --- /dev/null +++ b/packages/app/src/workspace/file-open/index.ts @@ -0,0 +1,59 @@ +export type OpenFileDisposition = "main" | "side"; + +export interface WorkspaceFileLocation { + path: string; + lineStart?: number; + lineEnd?: number; +} + +export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation; + +export interface WorkspaceFileOpenRequest { + location: WorkspaceFileLocation; + disposition: OpenFileDisposition; +} + +export function normalizeWorkspaceFileLocation( + location: WorkspaceFileLocation | null | undefined, +): WorkspaceFileLocation | null { + if (!location) { + return null; + } + + const path = location.path.trim().replace(/\\/g, "/"); + if (!path) { + return null; + } + + const lineStart = normalizeLineNumber(location.lineStart); + const lineEnd = normalizeLineNumber(location.lineEnd); + return { + path, + ...(lineStart ? { lineStart } : {}), + ...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}), + }; +} + +export function workspaceFileLocationsEqual( + left: WorkspaceFileLocation, + right: WorkspaceFileLocation, +): boolean { + return ( + left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd + ); +} + +export function createWorkspaceFileTabTarget( + location: WorkspaceFileLocation, +): WorkspaceFileTabTarget { + return { + kind: "file", + ...location, + }; +} + +function normalizeLineNumber(value: number | null | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +}