mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Support line ranges in assistant file links (#1067)
This commit is contained in:
13
packages/app/src/assistant-file-links/index.ts
Normal file
13
packages/app/src/assistant-file-links/index.ts
Normal file
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -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<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId,
|
||||
toast,
|
||||
});
|
||||
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
||||
filePath: string;
|
||||
disposition: OpenFileDisposition;
|
||||
}) {
|
||||
onOpenWorkspaceFile?.(input);
|
||||
});
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
// tracked in react-native-reanimated#8422.
|
||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||
@@ -336,8 +334,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
|
||||
if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
});
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onOpenWorkspaceFile) {
|
||||
openWorkspaceFile({ filePath: normalized.file, disposition });
|
||||
onOpenWorkspaceFile({
|
||||
location,
|
||||
disposition,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -345,7 +355,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: { kind: "file", path: normalized.file },
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -376,7 +386,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
setExplorerTabForCheckout,
|
||||
openWorkspaceFile,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@server/client/daemon-client";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
@@ -26,11 +26,13 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
lineNumber: number;
|
||||
gutterWidth: number;
|
||||
highlighted: boolean;
|
||||
}
|
||||
|
||||
interface FilePreviewBodyProps {
|
||||
@@ -38,7 +40,7 @@ interface FilePreviewBodyProps {
|
||||
isLoading: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
isMobile: boolean;
|
||||
filePath: string;
|
||||
location: WorkspaceFileLocation;
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
@@ -50,6 +52,11 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
return trimmed.length > 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 (
|
||||
<View style={codeLineStyles.line}>
|
||||
<View style={lineStyle}>
|
||||
<View style={gutterStyle}>
|
||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
||||
{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<RNScrollView>(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 (
|
||||
<View style={styles.centerState}>
|
||||
@@ -233,7 +293,17 @@ function FilePreviewBody({
|
||||
const codeLines = (
|
||||
<View>
|
||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
||||
<CodeLine key={key} tokens={tokens} lineNumber={lineNumber} gutterWidth={gutterWidth} />
|
||||
<CodeLine
|
||||
key={key}
|
||||
tokens={tokens}
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
highlighted={
|
||||
Boolean(lineSelection) &&
|
||||
lineNumber >= (lineSelection?.lineStart ?? 0) &&
|
||||
lineNumber <= (lineSelection?.lineEnd ?? 0)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<AgentPanelContent
|
||||
serverId={serverId}
|
||||
agentId={target.agentId}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={handleOpenWorkspaceFile}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<ReturnType<typeof useHostRuntimeClient>>;
|
||||
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<ReturnType<typeof useHostRuntimeClient>>;
|
||||
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<typeof useToastHost>["api"];
|
||||
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||
|
||||
@@ -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<typeof normalizeAgentSnapshot>[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}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ function FilePanel() {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} filePath={target.path} />;
|
||||
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} location={target} />;
|
||||
}
|
||||
|
||||
export const filePanelRegistration: PanelRegistration<"file"> = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>): 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 });
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type OpenFileDisposition = "main" | "side";
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open";
|
||||
|
||||
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["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, { kind: "file" }>,
|
||||
): 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);
|
||||
}
|
||||
63
packages/app/src/workspace/file-open/index.test.ts
Normal file
63
packages/app/src/workspace/file-open/index.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
59
packages/app/src/workspace/file-open/index.ts
Normal file
59
packages/app/src/workspace/file-open/index.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user