Support line ranges in assistant file links (#1067)
This commit is contained in:
@@ -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";
|
||||||
+3
-3
@@ -16,11 +16,11 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { classifyAssistantFileLink, type InlinePathTarget } from "@/utils/inline-path";
|
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||||
import type { AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
|
|
||||||
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
|
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse";
|
||||||
|
import type { AssistantFileLinkSource } from "./resolver";
|
||||||
|
|
||||||
interface AssistantInlinePathLinkProps {
|
interface AssistantInlinePathLinkProps {
|
||||||
content: string;
|
content: string;
|
||||||
+37
-1
@@ -5,7 +5,7 @@ import {
|
|||||||
parseAssistantFileLink,
|
parseAssistantFileLink,
|
||||||
parseFileProtocolUrl,
|
parseFileProtocolUrl,
|
||||||
parseInlinePathToken,
|
parseInlinePathToken,
|
||||||
} from "./inline-path";
|
} from "./parse";
|
||||||
|
|
||||||
describe("parseInlinePathToken", () => {
|
describe("parseInlinePathToken", () => {
|
||||||
it("returns null for plain paths without a line number", () => {
|
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", () => {
|
it("rejects range-only :line tokens", () => {
|
||||||
expect(parseInlinePathToken(":12")).toBeNull();
|
expect(parseInlinePathToken(":12")).toBeNull();
|
||||||
expect(parseInlinePathToken(":12-20")).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", () => {
|
it("parses file URLs without line fragments", () => {
|
||||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
|
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
|
||||||
raw: "file:///Users/test/project/src/app.tsx",
|
raw: "file:///Users/test/project/src/app.tsx",
|
||||||
+9
-3
@@ -1,4 +1,4 @@
|
|||||||
import { isAbsolutePath } from "./path";
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
export interface InlinePathTarget {
|
export interface InlinePathTarget {
|
||||||
raw: string;
|
raw: string;
|
||||||
@@ -8,7 +8,10 @@ export interface InlinePathTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FILE_PROTOCOL = "file:";
|
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([
|
const ASSISTANT_FILE_EXTENSIONS = new Set([
|
||||||
"astro",
|
"astro",
|
||||||
"bash",
|
"bash",
|
||||||
@@ -142,7 +145,10 @@ export function parseInlinePathToken(value: string): InlinePathTarget | null {
|
|||||||
return 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) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
+26
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
getAssistantFileLinkToken,
|
getAssistantFileLinkToken,
|
||||||
type AssistantFileLinkContext,
|
type AssistantFileLinkContext,
|
||||||
type DirectorySuggestionResult,
|
type DirectorySuggestionResult,
|
||||||
} from "./assistant-file-link-resolver";
|
} from "./resolver";
|
||||||
|
|
||||||
const CONTEXT: AssistantFileLinkContext = {
|
const CONTEXT: AssistantFileLinkContext = {
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
@@ -259,6 +259,31 @@ describe("assistant file link resolver", () => {
|
|||||||
expect(result.opened).toBe(true);
|
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 () => {
|
it("resolves inline-code basename line refs through directory suggestions", async () => {
|
||||||
const suggestions = vi.fn(async () =>
|
const suggestions = vi.fn(async () =>
|
||||||
resolvedSuggestions([
|
resolvedSuggestions([
|
||||||
+2
-2
@@ -3,8 +3,8 @@ import {
|
|||||||
isFileLookingAssistantToken,
|
isFileLookingAssistantToken,
|
||||||
type AssistantFileLinkClassification,
|
type AssistantFileLinkClassification,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./inline-path";
|
} from "./parse";
|
||||||
import type { OpenFileDisposition } from "./workspace-file-open";
|
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||||
|
|
||||||
export interface AssistantFileLinkContext {
|
export interface AssistantFileLinkContext {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
+3
-3
@@ -6,9 +6,9 @@ import {
|
|||||||
type AssistantFileLinkContext,
|
type AssistantFileLinkContext,
|
||||||
type AssistantFileLinkOpenInput,
|
type AssistantFileLinkOpenInput,
|
||||||
type AssistantFileLinkPrefetchInput,
|
type AssistantFileLinkPrefetchInput,
|
||||||
} from "@/utils/assistant-file-link-resolver";
|
} from "./resolver";
|
||||||
import type { InlinePathTarget } from "@/utils/inline-path";
|
import type { InlinePathTarget } from "./parse";
|
||||||
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
|
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
export interface UseAssistantFileLinkResolverOptions {
|
export interface UseAssistantFileLinkResolverOptions {
|
||||||
@@ -74,11 +74,15 @@ import {
|
|||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
} from "./use-bottom-anchor-controller";
|
} from "./use-bottom-anchor-controller";
|
||||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
import { normalizeInlinePathTarget } from "@/assistant-file-links";
|
||||||
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
|
import {
|
||||||
|
createWorkspaceFileTabTarget,
|
||||||
|
normalizeWorkspaceFileLocation,
|
||||||
|
type OpenFileDisposition,
|
||||||
|
type WorkspaceFileOpenRequest,
|
||||||
|
} from "@/workspace/file-open";
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
@@ -243,7 +247,7 @@ export interface AgentStreamViewProps {
|
|||||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||||
isAuthoritativeHistoryReady?: boolean;
|
isAuthoritativeHistoryReady?: boolean;
|
||||||
toast?: ToastApi | null;
|
toast?: ToastApi | null;
|
||||||
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||||
@@ -303,12 +307,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
agentId,
|
agentId,
|
||||||
toast,
|
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
|
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||||
// tracked in react-native-reanimated#8422.
|
// tracked in react-native-reanimated#8422.
|
||||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||||
@@ -336,8 +334,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.file) {
|
if (normalized.file) {
|
||||||
|
const location = normalizeWorkspaceFileLocation({
|
||||||
|
path: normalized.file,
|
||||||
|
lineStart: target.lineStart,
|
||||||
|
lineEnd: target.lineEnd,
|
||||||
|
});
|
||||||
|
if (!location) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (onOpenWorkspaceFile) {
|
if (onOpenWorkspaceFile) {
|
||||||
openWorkspaceFile({ filePath: normalized.file, disposition });
|
onOpenWorkspaceFile({
|
||||||
|
location,
|
||||||
|
disposition,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +355,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
navigateToPreparedWorkspaceTab({
|
navigateToPreparedWorkspaceTab({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
target: { kind: "file", path: normalized.file },
|
target: createWorkspaceFileTabTarget(location),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -376,7 +386,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
resolvedServerId,
|
resolvedServerId,
|
||||||
setExplorerTabForCheckout,
|
setExplorerTabForCheckout,
|
||||||
openWorkspaceFile,
|
|
||||||
workspaceId,
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import type { FileReadResult } from "@server/client/daemon-client";
|
import type { FileReadResult } from "@server/client/daemon-client";
|
||||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
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 { persistAttachmentFromBytes } from "@/attachments/service";
|
||||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||||
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
lineNumber: number;
|
lineNumber: number;
|
||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
|
highlighted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilePreviewBodyProps {
|
interface FilePreviewBodyProps {
|
||||||
@@ -38,7 +40,7 @@ interface FilePreviewBodyProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
showDesktopWebScrollbar: boolean;
|
showDesktopWebScrollbar: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
filePath: string;
|
location: WorkspaceFileLocation;
|
||||||
imagePreviewUri: string | null;
|
imagePreviewUri: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +52,11 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FileLineSelection {
|
||||||
|
lineStart: number;
|
||||||
|
lineEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
function formatFileSize({ size }: { size: number }): string {
|
||||||
if (size < 1024) {
|
if (size < 1024) {
|
||||||
return `${size} B`;
|
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 gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
|
||||||
|
const lineStyle = useMemo(
|
||||||
|
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
||||||
|
[highlighted],
|
||||||
|
);
|
||||||
const keyedTokens = useMemo(
|
const keyedTokens = useMemo(
|
||||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
||||||
[tokens],
|
[tokens],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={codeLineStyles.line}>
|
<View style={lineStyle}>
|
||||||
<View style={gutterStyle}>
|
<View style={gutterStyle}>
|
||||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
||||||
{String(lineNumber)}
|
{String(lineNumber)}
|
||||||
@@ -126,6 +157,9 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
|||||||
line: {
|
line: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
},
|
},
|
||||||
|
highlightedLine: {
|
||||||
|
backgroundColor: theme.colors.accentBorder,
|
||||||
|
},
|
||||||
gutter: {
|
gutter: {
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
paddingRight: theme.spacing[3],
|
paddingRight: theme.spacing[3],
|
||||||
@@ -152,13 +186,15 @@ function FilePreviewBody({
|
|||||||
isLoading,
|
isLoading,
|
||||||
showDesktopWebScrollbar,
|
showDesktopWebScrollbar,
|
||||||
isMobile,
|
isMobile,
|
||||||
filePath,
|
location,
|
||||||
imagePreviewUri,
|
imagePreviewUri,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const filePath = location.path;
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
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 previewScrollRef = useRef<RNScrollView>(null);
|
||||||
const webScrollbarStyle = useWebScrollbarStyle();
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
@@ -178,12 +214,36 @@ function FilePreviewBody({
|
|||||||
if (!highlightedLines) return 0;
|
if (!highlightedLines) return 0;
|
||||||
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
|
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
|
||||||
}, [highlightedLines, 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(
|
const imageSource = useMemo(
|
||||||
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
||||||
[imagePreviewUri],
|
[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) {
|
if (isLoading && !preview) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -233,7 +293,17 @@ function FilePreviewBody({
|
|||||||
const codeLines = (
|
const codeLines = (
|
||||||
<View>
|
<View>
|
||||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
{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>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -312,18 +382,18 @@ function FilePreviewBody({
|
|||||||
export function FilePane({
|
export function FilePane({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
filePath,
|
location,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
filePath: string;
|
location: WorkspaceFileLocation;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
|
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
|
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
|
||||||
@@ -366,7 +436,7 @@ export function FilePane({
|
|||||||
isLoading={query.isFetching}
|
isLoading={query.isFetching}
|
||||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
filePath={filePath}
|
location={location}
|
||||||
imagePreviewUri={imagePreviewUri}
|
imagePreviewUri={imagePreviewUri}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -68,11 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages";
|
|||||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||||
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
|
|
||||||
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
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 type { ToastApi } from "@/components/toast-host";
|
||||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
@@ -95,15 +92,19 @@ import { useToolCallSheet } from "./tool-call-sheet";
|
|||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import {
|
import {
|
||||||
AssistantInlineCodePathLink,
|
AssistantInlineCodePathLink,
|
||||||
|
classifyAssistantFileLink,
|
||||||
|
type AssistantFileLinkSource,
|
||||||
AssistantMarkdownCodeLink,
|
AssistantMarkdownCodeLink,
|
||||||
AssistantMarkdownLink,
|
AssistantMarkdownLink,
|
||||||
} from "./assistant-file-link";
|
type InlinePathTarget,
|
||||||
|
useAssistantFileLinkResolver,
|
||||||
|
} from "@/assistant-file-links";
|
||||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
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 }>;
|
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||||
|
|
||||||
@@ -1683,8 +1684,12 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
) => {
|
) => {
|
||||||
const content = node.content ?? "";
|
const content = node.content ?? "";
|
||||||
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
||||||
|
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
|
||||||
const shouldResolveInlinePath =
|
const shouldResolveInlinePath =
|
||||||
onInlinePathPress && !isLinkedInlineCode && parseInlinePathToken(content);
|
onInlinePathPress &&
|
||||||
|
!isLinkedInlineCode &&
|
||||||
|
inlineCodeFileLink &&
|
||||||
|
inlineCodeFileLink.kind !== "external";
|
||||||
|
|
||||||
if (shouldResolveInlinePath) {
|
if (shouldResolveInlinePath) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-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";
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
interface SplitContainerProps {
|
interface SplitContainerProps {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
} from "@/hooks/use-agent-screen-state-machine";
|
} from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
||||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
import {
|
import {
|
||||||
@@ -59,7 +58,7 @@ import type { PendingPermission } from "@/types/shared";
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
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 { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
@@ -289,18 +288,12 @@ function AgentPanel() {
|
|||||||
const { isInteractive } = usePaneFocus();
|
const { isInteractive } = usePaneFocus();
|
||||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||||
|
|
||||||
const handleOpenWorkspaceFile = useStableEvent(
|
|
||||||
({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => {
|
|
||||||
openFileInWorkspace(filePath, disposition);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentPanelContent
|
<AgentPanelContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={target.agentId}
|
agentId={target.agentId}
|
||||||
isPaneFocused={isInteractive}
|
isPaneFocused={isInteractive}
|
||||||
onOpenWorkspaceFile={handleOpenWorkspaceFile}
|
onOpenWorkspaceFile={openFileInWorkspace}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -366,7 +359,7 @@ function AgentPanelContent({
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const resolvedAgentId = agentId.trim() || undefined;
|
const resolvedAgentId = agentId.trim() || undefined;
|
||||||
const resolvedServerId = serverId.trim() || undefined;
|
const resolvedServerId = serverId.trim() || undefined;
|
||||||
@@ -428,7 +421,7 @@ function AgentPanelBody({
|
|||||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
connectionStatus: HostRuntimeConnectionStatus;
|
connectionStatus: HostRuntimeConnectionStatus;
|
||||||
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||||
@@ -593,7 +586,7 @@ function ChatAgentContent({
|
|||||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
connectionStatus: HostRuntimeConnectionStatus;
|
connectionStatus: HostRuntimeConnectionStatus;
|
||||||
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const panelToast = useToastHost();
|
const panelToast = useToastHost();
|
||||||
const { isArchivingAgent } = useArchiveAgent();
|
const { isArchivingAgent } = useArchiveAgent();
|
||||||
@@ -1042,7 +1035,7 @@ function AgentStreamSection({
|
|||||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||||
hasAppliedAuthoritativeHistory: boolean;
|
hasAppliedAuthoritativeHistory: boolean;
|
||||||
toast: ReturnType<typeof useToastHost>["api"];
|
toast: ReturnType<typeof useToastHost>["api"];
|
||||||
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const streamItemsRaw = useSessionStore((state) =>
|
const streamItemsRaw = useSessionStore((state) =>
|
||||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
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 type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
|
|
||||||
|
|
||||||
function useDraftPanelDescriptor() {
|
function useDraftPanelDescriptor() {
|
||||||
return {
|
return {
|
||||||
@@ -31,13 +30,6 @@ function DraftPanel() {
|
|||||||
const { isInteractive } = usePaneFocus();
|
const { isInteractive } = usePaneFocus();
|
||||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||||
|
|
||||||
const handleOpenWorkspaceFile = useCallback(
|
|
||||||
({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => {
|
|
||||||
openFileInWorkspace(filePath, disposition);
|
|
||||||
},
|
|
||||||
[openFileInWorkspace],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreated = useCallback(
|
const handleCreated = useCallback(
|
||||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||||
@@ -59,7 +51,7 @@ function DraftPanel() {
|
|||||||
draftId={target.draftId}
|
draftId={target.draftId}
|
||||||
initialSetup={target.setup}
|
initialSetup={target.setup}
|
||||||
isPaneFocused={isInteractive}
|
isPaneFocused={isInteractive}
|
||||||
onOpenWorkspaceFile={handleOpenWorkspaceFile}
|
onOpenWorkspaceFile={openFileInWorkspace}
|
||||||
onCreated={handleCreated}
|
onCreated={handleCreated}
|
||||||
onOpenImportSheet={openImportSheet}
|
onOpenImportSheet={openImportSheet}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function FilePanel() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} filePath={target.path} />;
|
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} location={target} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filePanelRegistration: PanelRegistration<"file"> = {
|
export const filePanelRegistration: PanelRegistration<"file"> = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { createContext, useContext, type ReactNode } from "react";
|
import React, { createContext, useContext, type ReactNode } from "react";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
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 {
|
export interface PaneContextValue {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -11,7 +11,7 @@ export interface PaneContextValue {
|
|||||||
openTab: (target: WorkspaceTabTarget) => void;
|
openTab: (target: WorkspaceTabTarget) => void;
|
||||||
closeCurrentTab: () => void;
|
closeCurrentTab: () => void;
|
||||||
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
|
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
|
||||||
openFileInWorkspace: (filePath: string, disposition: OpenFileDisposition) => void;
|
openFileInWorkspace: (request: WorkspaceFileOpenRequest) => void;
|
||||||
openImportSheet: () => void;
|
openImportSheet: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { Agent } from "@/stores/session-store";
|
|||||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
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 { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||||
@@ -319,7 +319,7 @@ interface WorkspaceDraftAgentTabProps {
|
|||||||
initialSetup?: WorkspaceDraftTabSetup;
|
initialSetup?: WorkspaceDraftTabSetup;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||||
onOpenWorkspaceFile: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
|
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
||||||
onOpenImportSheet?: () => void;
|
onOpenImportSheet?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
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 {
|
export interface WorkspacePaneContentModel {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -24,7 +24,7 @@ export interface BuildWorkspacePaneContentModelInput {
|
|||||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||||
onCloseCurrentTab: () => void;
|
onCloseCurrentTab: () => void;
|
||||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||||
onOpenWorkspaceFile: (filePath: string, disposition: OpenFileDisposition) => void;
|
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
||||||
onOpenImportSheet: () => void;
|
onOpenImportSheet: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,30 @@ describe("workspace-pane-state", () => {
|
|||||||
).toEqual({ kind: "open-in-source" });
|
).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", () => {
|
it("resolves side file opens to an existing right pane", () => {
|
||||||
const layout: WorkspaceLayout = {
|
const layout: WorkspaceLayout = {
|
||||||
root: {
|
root: {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
buildDeterministicWorkspaceTabId,
|
buildDeterministicWorkspaceTabId,
|
||||||
normalizeWorkspaceTabTarget,
|
normalizeWorkspaceTabTarget,
|
||||||
workspaceTabTargetsEqual,
|
workspaceTabTargetsEqual,
|
||||||
} from "@/utils/workspace-tab-identity";
|
} from "@/workspace-tabs/identity";
|
||||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||||
|
|
||||||
export interface WorkspaceDerivedTab {
|
export interface WorkspaceDerivedTab {
|
||||||
@@ -209,7 +209,10 @@ export function resolveSideFileOpenPlacement(input: {
|
|||||||
tabs: WorkspaceTab[];
|
tabs: WorkspaceTab[];
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
}): WorkspaceSideFileOpenPlacement {
|
}): 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) {
|
if (existingTab) {
|
||||||
return { kind: "open-in-source" };
|
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 { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import {
|
import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||||
normalizeWorkspaceTabTarget,
|
|
||||||
workspaceTabTargetsEqual,
|
|
||||||
} from "@/utils/workspace-tab-identity";
|
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
@@ -161,6 +158,12 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
|||||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||||
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
|
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
|
||||||
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
|
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 WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||||
@@ -2023,7 +2026,11 @@ function WorkspaceScreenContent({
|
|||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
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) {
|
if (tabId) {
|
||||||
navigateToTabId(tabId);
|
navigateToTabId(tabId);
|
||||||
}
|
}
|
||||||
@@ -2032,28 +2039,40 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenFileFromChat = useCallback(
|
const handleOpenFileFromChat = useCallback(
|
||||||
(filePath: string) => {
|
(location: WorkspaceFileLocation) => {
|
||||||
const normalizedFilePath = filePath.trim();
|
const normalizedLocation = normalizeWorkspaceFileLocation(location);
|
||||||
if (!normalizedFilePath) {
|
if (!normalizedLocation) {
|
||||||
return;
|
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(
|
const handleOpenFileFromChatInSidePane = useCallback(
|
||||||
(input: { filePath: string; sourcePaneId?: string }) => {
|
(input: { location: WorkspaceFileLocation; sourcePaneId?: string }) => {
|
||||||
const normalizedFilePath = input.filePath.trim();
|
const location = normalizeWorkspaceFileLocation(input.location);
|
||||||
if (!normalizedFilePath) {
|
if (!location) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!persistenceKey || isMobile || !input.sourcePaneId) {
|
if (!persistenceKey || isMobile || !input.sourcePaneId) {
|
||||||
handleOpenFileFromExplorer(normalizedFilePath);
|
handleOpenFileFromChat(location);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target: WorkspaceTabTarget = { kind: "file", path: normalizedFilePath };
|
const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
|
||||||
const placement = resolveSideFileOpenPlacement({
|
const placement = resolveSideFileOpenPlacement({
|
||||||
layout: workspaceLayout,
|
layout: workspaceLayout,
|
||||||
sourcePaneId: input.sourcePaneId,
|
sourcePaneId: input.sourcePaneId,
|
||||||
@@ -2075,7 +2094,7 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
handleOpenFileFromExplorer,
|
handleOpenFileFromChat,
|
||||||
isMobile,
|
isMobile,
|
||||||
focusWorkspacePane,
|
focusWorkspacePane,
|
||||||
navigateToTabId,
|
navigateToTabId,
|
||||||
@@ -2755,18 +2774,18 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
||||||
},
|
},
|
||||||
onOpenWorkspaceFile: (filePath, disposition) => {
|
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
|
||||||
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
|
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
|
||||||
focusWorkspacePane(persistenceKey, input.paneId);
|
focusWorkspacePane(persistenceKey, input.paneId);
|
||||||
}
|
}
|
||||||
if (disposition === "side") {
|
if (request.disposition === "side") {
|
||||||
handleOpenFileFromChatInSidePane({
|
handleOpenFileFromChatInSidePane({
|
||||||
filePath,
|
location: request.location,
|
||||||
sourcePaneId: input.paneId ?? undefined,
|
sourcePaneId: input.paneId ?? undefined,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleOpenFileFromChat(filePath);
|
handleOpenFileFromChat(request.location);
|
||||||
},
|
},
|
||||||
onOpenImportSheet: openImportSheet,
|
onOpenImportSheet: openImportSheet,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
deriveWorkspacePaneState,
|
deriveWorkspacePaneState,
|
||||||
type WorkspaceDerivedTab,
|
type WorkspaceDerivedTab,
|
||||||
} from "@/screens/workspace/workspace-pane-state";
|
} from "@/screens/workspace/workspace-pane-state";
|
||||||
import { buildDeterministicWorkspaceTabId } from "@/utils/workspace-tab-identity";
|
import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
|
||||||
|
|
||||||
export interface WorkspaceTabModel {
|
export interface WorkspaceTabModel {
|
||||||
tabs: WorkspaceDerivedTab[];
|
tabs: WorkspaceDerivedTab[];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
buildDeterministicWorkspaceTabId,
|
buildDeterministicWorkspaceTabId,
|
||||||
normalizeWorkspaceTabTarget,
|
normalizeWorkspaceTabTarget,
|
||||||
workspaceTabTargetsEqual,
|
workspaceTabTargetsEqual,
|
||||||
} from "@/utils/workspace-tab-identity";
|
} from "@/workspace-tabs/identity";
|
||||||
|
|
||||||
export interface SplitPane {
|
export interface SplitPane {
|
||||||
id: string;
|
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 {
|
export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||||
const layout = asInternalLayout(input.layout);
|
const layout = asInternalLayout(input.layout);
|
||||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
const existingTab = findExistingTabForTarget(layout.root, input.target);
|
||||||
workspaceTabTargetsEqual(tab.target, input.target),
|
|
||||||
);
|
|
||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
|
const nextLayout = updateExistingTabTarget(layout, existingTab, input.target);
|
||||||
return {
|
return {
|
||||||
tabId: existingTab.tabId,
|
tabId: existingTab.tabId,
|
||||||
layout:
|
layout:
|
||||||
focusTabInLayout({
|
focusTabInLayout({
|
||||||
layout,
|
layout: nextLayout,
|
||||||
tabId: existingTab.tabId,
|
tabId: existingTab.tabId,
|
||||||
}) ?? input.layout,
|
}) ?? nextLayout,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,11 +1098,12 @@ export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLa
|
|||||||
|
|
||||||
export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||||
const layout = asInternalLayout(input.layout);
|
const layout = asInternalLayout(input.layout);
|
||||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
const existingTab = findExistingTabForTarget(layout.root, input.target);
|
||||||
workspaceTabTargetsEqual(tab.target, input.target),
|
|
||||||
);
|
|
||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
return { tabId: existingTab.tabId, layout: input.layout };
|
return {
|
||||||
|
tabId: existingTab.tabId,
|
||||||
|
layout: updateExistingTabTarget(layout, existingTab, input.target),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return insertNewTabIntoFocusedPane({ ...input, focus: false });
|
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", () => {
|
it("openTabInBackground inserts a tab without stealing focus", () => {
|
||||||
const workspaceKey = createWorkspaceKey();
|
const workspaceKey = createWorkspaceKey();
|
||||||
const store = workspaceLayoutStore.getState();
|
const store = workspaceLayoutStore.getState();
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import {
|
|||||||
type WorkspaceTabSnapshot,
|
type WorkspaceTabSnapshot,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
} from "@/stores/workspace-layout-actions";
|
} from "@/stores/workspace-layout-actions";
|
||||||
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity";
|
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
|
||||||
|
|
||||||
export { buildWorkspaceTabPersistenceKey };
|
export { buildWorkspaceTabPersistenceKey };
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
normalizeWorkspaceDraftTabSetup,
|
normalizeWorkspaceDraftTabSetup,
|
||||||
normalizeWorkspaceTabTarget,
|
normalizeWorkspaceTabTarget,
|
||||||
workspaceTabTargetsEqual,
|
workspaceTabTargetsEqual,
|
||||||
} from "@/utils/workspace-tab-identity";
|
} from "@/workspace-tabs/identity";
|
||||||
|
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
|
||||||
|
|
||||||
export interface WorkspaceDraftTabSetup {
|
export interface WorkspaceDraftTabSetup {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -23,7 +24,7 @@ export type WorkspaceTabTarget =
|
|||||||
| { kind: "agent"; agentId: string }
|
| { kind: "agent"; agentId: string }
|
||||||
| { kind: "terminal"; terminalId: string }
|
| { kind: "terminal"; terminalId: string }
|
||||||
| { kind: "browser"; browserId: string }
|
| { kind: "browser"; browserId: string }
|
||||||
| { kind: "file"; path: string }
|
| WorkspaceFileTabTarget
|
||||||
| { kind: "setup"; workspaceId: string };
|
| { kind: "setup"; workspaceId: string };
|
||||||
|
|
||||||
export interface WorkspaceTab {
|
export interface WorkspaceTab {
|
||||||
@@ -168,7 +169,12 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
|
|||||||
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
|
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
|
||||||
}
|
}
|
||||||
if (kind === "file" && typeof raw.path === "string") {
|
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") {
|
if (kind === "setup" && typeof raw.workspaceId === "string") {
|
||||||
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
|
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export type OpenFileDisposition = "main" | "side";
|
|
||||||
+10
-3
@@ -1,4 +1,5 @@
|
|||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
|
import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open";
|
||||||
|
|
||||||
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>;
|
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>;
|
||||||
|
|
||||||
@@ -29,8 +30,7 @@ export function normalizeWorkspaceTabTarget(
|
|||||||
return browserId ? { kind: "browser", browserId } : null;
|
return browserId ? { kind: "browser", browserId } : null;
|
||||||
}
|
}
|
||||||
if (value.kind === "file") {
|
if (value.kind === "file") {
|
||||||
const path = trimNonEmpty(value.path);
|
return normalizeFileTabTarget(value);
|
||||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
|
||||||
}
|
}
|
||||||
if (value.kind === "setup") {
|
if (value.kind === "setup") {
|
||||||
const workspaceId = trimNonEmpty(value.workspaceId);
|
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||||
@@ -83,7 +83,7 @@ export function workspaceTabTargetsEqual(
|
|||||||
return left.browserId === right.browserId;
|
return left.browserId === right.browserId;
|
||||||
}
|
}
|
||||||
if (left.kind === "file" && right.kind === "file") {
|
if (left.kind === "file" && right.kind === "file") {
|
||||||
return left.path === right.path;
|
return workspaceFileLocationsEqual(left, right);
|
||||||
}
|
}
|
||||||
if (left.kind === "setup" && right.kind === "setup") {
|
if (left.kind === "setup" && right.kind === "setup") {
|
||||||
return left.workspaceId === right.workspaceId;
|
return left.workspaceId === right.workspaceId;
|
||||||
@@ -151,6 +151,13 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : 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 {
|
function trimOptionalString(value: string | null | undefined): string | null {
|
||||||
return value == null ? null : trimNonEmpty(value);
|
return value == null ? null : trimNonEmpty(value);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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