diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index b587cade326..29e83b40519 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -186,6 +186,9 @@ function FilePaneContent({ context, workspaceId }: FilePaneProps) { document={document} filePath={filePath} workspaceId={workspaceId} + initialLine={data.line} + initialColumn={data.column} + cursorRequestId={data.cursorRequestId} onChangeView={handleChangeView} onForceView={handleForceView} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts index 9b1dfdb664c..a87c0e198f6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts @@ -34,6 +34,9 @@ export interface ViewProps { document: SharedFileDocument; filePath: string; workspaceId: string; + initialLine?: number; + initialColumn?: number; + cursorRequestId?: string; onChangeView: (viewId: string) => void; onForceView: (viewId: string) => void; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx index 1ae7283e6fc..ebec8bc37f1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx @@ -2,7 +2,13 @@ import { detectLanguage } from "shared/detect-language"; import type { ViewProps } from "../../types"; import { CodeEditor } from "./components/CodeEditor"; -export function CodeView({ document, filePath }: ViewProps) { +export function CodeView({ + document, + filePath, + initialLine, + initialColumn, + cursorRequestId, +}: ViewProps) { if (document.content.kind !== "text") { return null; } @@ -12,6 +18,9 @@ export function CodeView({ document, filePath }: ViewProps) { key={document.id} value={document.content.value} language={detectLanguage(filePath)} + initialLine={initialLine} + initialColumn={initialColumn} + cursorRequestId={cursorRequestId} onChange={(next) => document.setContent(next)} onSave={() => void document.save()} fillHeight diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx index e47e58fa9c8..3fb167d6c4a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx @@ -12,7 +12,7 @@ import { indentOnInput, } from "@codemirror/language"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; -import { Compartment, EditorState } from "@codemirror/state"; +import { Compartment, EditorSelection, EditorState } from "@codemirror/state"; import { drawSelection, dropCursor, @@ -26,7 +26,7 @@ import { import { colorPicker } from "@replit/codemirror-css-color-picker"; import { cn } from "@superset/ui/utils"; import { useQuery } from "@tanstack/react-query"; -import { type MutableRefObject, useEffect, useRef } from "react"; +import { type MutableRefObject, useEffect, useRef, useState } from "react"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useResolvedTheme } from "renderer/stores/theme"; import { @@ -44,6 +44,9 @@ import { getCodeSyntaxHighlighting } from "./syntax-highlighting"; interface CodeEditorProps { value: string; language: string; + initialLine?: number; + initialColumn?: number; + cursorRequestId?: string; readOnly?: boolean; fillHeight?: boolean; className?: string; @@ -55,6 +58,9 @@ interface CodeEditorProps { export function CodeEditor({ value, language, + initialLine, + initialColumn, + cursorRequestId, readOnly = false, fillHeight = true, className, @@ -64,6 +70,7 @@ export function CodeEditor({ }: CodeEditorProps) { const containerRef = useRef(null); const viewRef = useRef(null); + const [viewReady, setViewReady] = useState(false); const languageCompartment = useRef(new Compartment()).current; const themeCompartment = useRef(new Compartment()).current; const editableCompartment = useRef(new Compartment()).current; @@ -160,6 +167,7 @@ export function CodeEditor({ if (editorRef) { editorRef.current = adapter; } + setViewReady(true); return () => { if (editorRef?.current === adapter) { @@ -258,6 +266,28 @@ export function CodeEditor({ }; }, [language, languageCompartment]); + useEffect(() => { + if (initialLine === undefined || cursorRequestId === undefined) { + return; + } + const view = viewRef.current; + if (!view || !viewReady) { + return; + } + + const safeLine = Math.max(1, Math.min(initialLine, view.state.doc.lines)); + const lineInfo = view.state.doc.line(safeLine); + const col = initialColumn ?? 1; + const offset = Math.min(col - 1, lineInfo.length); + const anchor = lineInfo.from + Math.max(0, offset); + + view.dispatch({ + selection: EditorSelection.cursor(anchor), + scrollIntoView: true, + }); + view.focus(); + }, [cursorRequestId, viewReady, initialLine, initialColumn]); + return (
{ + ( + filePath: string, + displayName?: string, + location?: { line?: number; column?: number }, + ) => { recordRecentlyViewed(filePath); const state = store.getState(); + const cursorRequestId = + location?.line !== undefined ? crypto.randomUUID() : undefined; const active = state.getActivePane(); if ( active?.pane.kind === "file" && (active.pane.data as FilePaneData).filePath === filePath ) { - if ( - displayName && - (active.pane.data as FilePaneData).displayName !== displayName - ) { + const activeData = active.pane.data as FilePaneData; + const shouldUpdateData = + (displayName && activeData.displayName !== displayName) || + location?.line !== undefined || + location?.column !== undefined; + if (shouldUpdateData) { state.setPaneData({ paneId: active.pane.id, data: { - ...(active.pane.data as FilePaneData), - displayName, + ...activeData, + displayName: displayName ?? activeData.displayName, + line: location?.line, + column: location?.column, + cursorRequestId, } as FilePaneData, }); } @@ -261,6 +272,9 @@ function WorkspaceContent({ filePath, mode: "editor", displayName, + line: location?.line, + column: location?.column, + cursorRequestId, } as FilePaneData, }, }); @@ -506,7 +520,7 @@ function WorkspaceContent({ navigate, openFilePaths: openFilePathsList, recentFilePaths: recentFilePathsList, - onSelectFile: ({ close, filePath, targetWorkspaceId }) => { + onSelectFile: ({ close, filePath, targetWorkspaceId, line, column }) => { close(); if (targetWorkspaceId !== workspaceId) { void navigate({ @@ -515,7 +529,7 @@ function WorkspaceContent({ }); return; } - openFilePane(filePath); + openFilePane(filePath, undefined, { line, column }); }, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts index 3d7fa09d655..c201e11b0f5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -4,6 +4,9 @@ export interface FilePaneData { /** FORK NOTE: carried for memo tabs so the tab title shows the * memo-derived displayName instead of the random filename. */ displayName?: string; + line?: number; + column?: number; + cursorRequestId?: string; language?: string; viewId?: string; forceViewId?: string; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts index bcc69d703b7..38a4a89f6f1 100644 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts @@ -1,2 +1,3 @@ export { CommandPalette } from "./CommandPalette"; +export { parseQuickOpenQuery } from "./parseQuickOpenQuery"; export { useCommandPalette } from "./useCommandPalette"; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/parseQuickOpenQuery.test.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/parseQuickOpenQuery.test.ts new file mode 100644 index 00000000000..4c83a9a3b1e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/parseQuickOpenQuery.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { parseQuickOpenQuery } from "./parseQuickOpenQuery"; + +describe("parseQuickOpenQuery", () => { + it("returns the query as-is when no line suffix", () => { + expect(parseQuickOpenQuery("foo.ts")).toEqual({ + searchQuery: "foo.ts", + }); + }); + + it("returns the query as-is for empty string", () => { + expect(parseQuickOpenQuery("")).toEqual({ + searchQuery: "", + }); + }); + + it("parses file:line", () => { + expect(parseQuickOpenQuery("foo.ts:123")).toEqual({ + searchQuery: "foo.ts", + line: 123, + }); + }); + + it("parses file:line:column", () => { + expect(parseQuickOpenQuery("foo.ts:123:45")).toEqual({ + searchQuery: "foo.ts", + line: 123, + column: 45, + }); + }); + + it("parses path with directories and line", () => { + expect(parseQuickOpenQuery("src/components/App.tsx:42")).toEqual({ + searchQuery: "src/components/App.tsx", + line: 42, + }); + }); + + it("returns query as-is when line is zero", () => { + expect(parseQuickOpenQuery("foo.ts:0")).toEqual({ + searchQuery: "foo.ts:0", + }); + }); + + it("returns query as-is when line is negative", () => { + expect(parseQuickOpenQuery("foo.ts:-5")).toEqual({ + searchQuery: "foo.ts:-5", + }); + }); + + it("returns query as-is for non-numeric suffix", () => { + expect(parseQuickOpenQuery("foo.ts:abc")).toEqual({ + searchQuery: "foo.ts:abc", + }); + }); + + it("returns query as-is when path part is empty", () => { + expect(parseQuickOpenQuery(":123")).toEqual({ + searchQuery: ":123", + }); + }); + + it("trims whitespace", () => { + expect(parseQuickOpenQuery(" foo.ts:10 ")).toEqual({ + searchQuery: "foo.ts", + line: 10, + }); + }); + + it("returns query as-is for column zero", () => { + expect(parseQuickOpenQuery("foo.ts:10:0")).toEqual({ + searchQuery: "foo.ts:10:0", + }); + }); + + it("handles Windows-style paths", () => { + expect(parseQuickOpenQuery("src\\utils\\helpers.ts:99")).toEqual({ + searchQuery: "src\\utils\\helpers.ts", + line: 99, + }); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/parseQuickOpenQuery.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/parseQuickOpenQuery.ts new file mode 100644 index 00000000000..678d5f19b0d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/parseQuickOpenQuery.ts @@ -0,0 +1,66 @@ +interface ParsedQuickOpenQuery { + searchQuery: string; + line?: number; + column?: number; +} + +export function parseQuickOpenQuery(query: string): ParsedQuickOpenQuery { + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + return { searchQuery: "" }; + } + + // Try to extract :line[:column] from the end of the query. + // We need to handle paths like "foo.ts:123" and "foo.ts:123:45" + // but NOT "C:\path" (drive letter colon). + const lastColon = trimmedQuery.lastIndexOf(":"); + if (lastColon <= 0) { + return { searchQuery: trimmedQuery }; + } + + const afterLastColon = trimmedQuery.slice(lastColon + 1); + if (!/^\d+$/.test(afterLastColon)) { + return { searchQuery: trimmedQuery }; + } + + const maybeColumn = Number.parseInt(afterLastColon, 10); + + // Check if there's another colon:digits before this one (line:column pattern) + const beforeLastColon = trimmedQuery.slice(0, lastColon); + const secondLastColon = beforeLastColon.lastIndexOf(":"); + let pathPart: string; + let line: number; + let column: number | undefined; + + if ( + secondLastColon > 0 && + /^\d+$/.test(beforeLastColon.slice(secondLastColon + 1)) + ) { + // Pattern: path:line:column + const maybeLine = Number.parseInt( + beforeLastColon.slice(secondLastColon + 1), + 10, + ); + pathPart = beforeLastColon.slice(0, secondLastColon).trim(); + line = maybeLine; + column = maybeColumn; + + if (!pathPart || line <= 0 || column <= 0) { + return { searchQuery: trimmedQuery }; + } + } else { + // Pattern: path:line + pathPart = beforeLastColon.trim(); + line = maybeColumn; + + if (!pathPart || line <= 0) { + return { searchQuery: trimmedQuery }; + } + } + + return { + searchQuery: pathPart, + line, + column, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts index 78d8bf763a1..9dcbd567a9d 100644 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts @@ -11,6 +11,7 @@ import { useSearchDialogStore, } from "renderer/stores/search-dialog-state"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { parseQuickOpenQuery } from "./parseQuickOpenQuery"; const SEARCH_LIMIT = 50; @@ -21,6 +22,8 @@ interface UseCommandPaletteParams { onSelectFile?: (input: { filePath: string; targetWorkspaceId: string; + line?: number; + column?: number; close: () => void; navigate: UseNavigateResult; }) => void; @@ -160,11 +163,13 @@ export function useCommandPalette({ const toggleIncludeIgnored = useFileExplorerStore( (s) => s.toggleIncludeIgnored, ); + const parsedQuery = useMemo(() => parseQuickOpenQuery(query), [query]); + const searchQuery = parsedQuery.searchQuery; // Single-workspace search (existing behavior) const singleSearch = useFileSearch({ workspaceId: open && scope === "workspace" ? workspaceId : undefined, - searchTerm: query, + searchTerm: searchQuery, includePattern, excludePattern, limit: SEARCH_LIMIT, @@ -177,7 +182,7 @@ export function useCommandPalette({ // Multi-workspace search. Note that MRU/open boosts aren't forwarded here // because the recency lists are scoped to the current workspace; applying // them across other workspaces would mis-rank unrelated paths. - const debouncedQuery = useDebouncedValue(query.trim(), 150); + const debouncedQuery = useDebouncedValue(searchQuery, 150); const multiSearchQueries = electronTrpc.useQueries((t) => open && scope === "global" && roots.length > 0 && debouncedQuery.length > 0 ? roots.map((root) => @@ -220,7 +225,7 @@ export function useCommandPalette({ scope === "workspace" ? singleSearch.isFetching : multiSearchQueries.some((query) => query.isFetching) || - (query.trim().length > 0 && query.trim() !== debouncedQuery); + (searchQuery.length > 0 && searchQuery !== debouncedQuery); const handleOpenChange = useCallback((nextOpen: boolean) => { setOpen(nextOpen); @@ -252,6 +257,8 @@ export function useCommandPalette({ onSelectFile({ filePath, targetWorkspaceId: targetWs, + line: parsedQuery.line, + column: parsedQuery.column, close: () => handleOpenChange(false), navigate, }); @@ -259,6 +266,8 @@ export function useCommandPalette({ } useTabsStore.getState().addFileViewerPane(targetWs, { filePath, + line: parsedQuery.line, + column: parsedQuery.column, useRightSidebarOpenViewWidth: true, }); handleOpenChange(false); @@ -266,7 +275,14 @@ export function useCommandPalette({ navigateToWorkspace(targetWs, navigate); } }, - [workspaceId, onSelectFile, handleOpenChange, navigate], + [ + workspaceId, + onSelectFile, + handleOpenChange, + navigate, + parsedQuery.line, + parsedQuery.column, + ], ); const setIncludePattern = useCallback(