From 9ede17fc6b9751c191be97227beb02c3e143ec59 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 11 Apr 2026 13:50:32 +0900 Subject: [PATCH 1/4] feat(desktop): add native reference graph for code symbol visualization Integrate reference-graph functionality natively into Superset Desktop, providing interactive graph visualization of code symbol references and call hierarchies using the existing LSP infrastructure. - Extend LanguageServiceProvider interface with findReferences, prepareCallHierarchy, and getIncomingCalls methods - Implement LSP-based methods in ExternalLspLanguageProvider - Implement tsserver-based methods in TypeScriptLanguageProvider - Add GraphBuilder service that constructs reference graphs via LSP - Add referenceGraph tRPC router - Add ReferenceGraphPane UI with @xyflow/react + ELK.js auto-layout - Add "reference-graph" pane type with tab store support - Add "Show Reference Graph" to editor context menu --- apps/desktop/package.json | 2 + apps/desktop/src/lib/trpc/routers/index.ts | 2 + .../lib/trpc/routers/reference-graph/index.ts | 61 +++ .../lsp/ExternalLspLanguageProvider.ts | 206 +++++++++ .../src/main/lib/language-services/manager.ts | 44 ++ .../typescript/TypeScriptLanguageProvider.ts | 165 ++++++++ .../src/main/lib/language-services/types.ts | 73 ++++ .../main/lib/reference-graph/graph-builder.ts | 393 ++++++++++++++++++ .../src/main/lib/reference-graph/index.ts | 7 + .../src/main/lib/reference-graph/types.ts | 53 +++ .../ReferenceGraphPane/ReferenceGraphPane.tsx | 288 +++++++++++++ .../ReferenceGraphPane/ReferenceNode.tsx | 107 +++++ .../TabView/ReferenceGraphPane/index.ts | 1 + .../ContentView/TabsContent/TabView/index.tsx | 24 ++ .../EditorContextMenu/EditorContextMenu.tsx | 13 + .../desktop/src/renderer/stores/tabs/store.ts | 47 +++ .../desktop/src/renderer/stores/tabs/types.ts | 7 + .../desktop/src/renderer/stores/tabs/utils.ts | 53 +++ apps/desktop/src/shared/tabs-types.ts | 18 +- bun.lock | 4 + 20 files changed, 1567 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/trpc/routers/reference-graph/index.ts create mode 100644 apps/desktop/src/main/lib/reference-graph/graph-builder.ts create mode 100644 apps/desktop/src/main/lib/reference-graph/index.ts create mode 100644 apps/desktop/src/main/lib/reference-graph/types.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceGraphPane.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceNode.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/index.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 09e294999e7..b2e816af771 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -150,6 +150,7 @@ "@xterm/addon-webgl": "0.20.0-beta.194", "@xterm/headless": "6.1.0-beta.195", "@xterm/xterm": "6.1.0-beta.195", + "@xyflow/react": "^12.10.2", "ai": "^6.0.0", "ansi_up": "^6.0.6", "better-auth": "1.5.6", @@ -170,6 +171,7 @@ "dotenv": "^17.3.1", "drizzle-orm": "0.45.1", "electron-updater": "^6.7.3", + "elkjs": "^0.11.1", "exceljs": "^4.4.0", "execa": "^9.6.0", "express": "^5.1.0", diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index ff35d9ee07a..0911ffbb6e2 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -26,6 +26,7 @@ import { createNotificationsRouter } from "./notifications"; import { createPermissionsRouter } from "./permissions"; import { createPortsRouter } from "./ports"; import { createProjectsRouter } from "./projects"; +import { createReferenceGraphRouter } from "./reference-graph"; import { createResourceMetricsRouter } from "./resource-metrics"; import { createRingtoneRouter } from "./ringtone"; import { createSettingsRouter } from "./settings"; @@ -63,6 +64,7 @@ export const createAppRouter = ( resourceMetrics: createResourceMetricsRouter(), menu: createMenuRouter(), languageServices: createLanguageServicesRouter(), + referenceGraph: createReferenceGraphRouter(), external: createExternalRouter(), settings: createSettingsRouter(), config: createConfigRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts b/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts new file mode 100644 index 00000000000..7c0d96ea5b0 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts @@ -0,0 +1,61 @@ +import { TRPCError } from "@trpc/server"; +import { buildReferenceGraph } from "main/lib/reference-graph"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; +import { getWorkspace } from "../workspaces/utils/db-helpers"; +import { getWorkspacePath } from "../workspaces/utils/worktree"; + +function resolveWorkspacePath(workspaceId: string): string { + const workspace = getWorkspace(workspaceId); + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace ${workspaceId} not found`, + }); + } + + const workspacePath = getWorkspacePath(workspace); + if (!workspacePath) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Workspace ${workspaceId} has no filesystem path`, + }); + } + + return workspacePath; +} + +export const createReferenceGraphRouter = () => { + return router({ + buildGraph: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + absolutePath: z.string(), + languageId: z.string(), + line: z.number().int().positive(), + column: z.number().int().positive(), + maxDepth: z.number().int().min(1).max(10).optional(), + maxNodes: z.number().int().min(1).max(500).optional(), + excludePatterns: z.array(z.string()).optional(), + }), + ) + .mutation(async ({ input }) => { + const workspacePath = resolveWorkspacePath(input.workspaceId); + + const graph = await buildReferenceGraph({ + workspaceId: input.workspaceId, + workspacePath, + absolutePath: input.absolutePath, + languageId: input.languageId, + line: input.line, + column: input.column, + maxDepth: input.maxDepth, + maxNodes: input.maxNodes, + excludePatterns: input.excludePatterns, + }); + + return graph; + }), + }); +}; diff --git a/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts index 9e12b603cf0..3a9a29fde20 100644 --- a/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts +++ b/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts @@ -1,7 +1,10 @@ import { languageDiagnosticsStore } from "../diagnostics-store"; import type { + LanguageServiceCallHierarchyItem, LanguageServiceDiagnostic, LanguageServiceDocument, + LanguageServiceIncomingCall, + LanguageServiceLocation, LanguageServiceProvider, LanguageServiceProviderSummary, LanguageServiceRelatedInformation, @@ -335,6 +338,209 @@ export class ExternalLspLanguageProvider implements LanguageServiceProvider { this.workspaceErrors.delete(args.workspaceId); } + async findReferences(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + line: number; + column: number; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) return null; + + try { + const result = (await session.client.request("textDocument/references", { + textDocument: { + uri: absolutePathToFileUri(args.absolutePath), + }, + position: { + line: args.line - 1, + character: args.column - 1, + }, + context: { includeDeclaration: true }, + })) as Array<{ + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + }> | null; + + if (!result) return null; + + return result + .map((loc) => { + const absPath = fileUriToAbsolutePath(loc.uri); + if (!absPath) return null; + return { + absolutePath: absPath, + line: loc.range.start.line + 1, + column: loc.range.start.character + 1, + endLine: loc.range.end.line + 1, + endColumn: loc.range.end.character + 1, + }; + }) + .filter((loc): loc is LanguageServiceLocation => loc !== null); + } catch { + return null; + } + } + + async prepareCallHierarchy(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + line: number; + column: number; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) return null; + + try { + const result = (await session.client.request( + "textDocument/prepareCallHierarchy", + { + textDocument: { + uri: absolutePathToFileUri(args.absolutePath), + }, + position: { + line: args.line - 1, + character: args.column - 1, + }, + }, + )) as Array<{ + name: string; + kind: number; + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + selectionRange: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + }> | null; + + if (!result) return null; + + return result + .map((item) => { + const absPath = fileUriToAbsolutePath(item.uri); + if (!absPath) return null; + return { + name: item.name, + kind: String(item.kind), + absolutePath: absPath, + line: item.range.start.line + 1, + column: item.range.start.character + 1, + endLine: item.range.end.line + 1, + endColumn: item.range.end.character + 1, + selectionLine: item.selectionRange.start.line + 1, + selectionColumn: item.selectionRange.start.character + 1, + selectionEndLine: item.selectionRange.end.line + 1, + selectionEndColumn: item.selectionRange.end.character + 1, + }; + }) + .filter( + (item): item is LanguageServiceCallHierarchyItem => item !== null, + ); + } catch { + return null; + } + } + + async getIncomingCalls(args: { + workspaceId: string; + item: LanguageServiceCallHierarchyItem; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) return null; + + try { + const lspItem = { + name: args.item.name, + kind: Number(args.item.kind), + uri: absolutePathToFileUri(args.item.absolutePath), + range: { + start: { + line: args.item.line - 1, + character: args.item.column - 1, + }, + end: { + line: args.item.endLine - 1, + character: args.item.endColumn - 1, + }, + }, + selectionRange: { + start: { + line: args.item.selectionLine - 1, + character: args.item.selectionColumn - 1, + }, + end: { + line: args.item.selectionEndLine - 1, + character: args.item.selectionEndColumn - 1, + }, + }, + }; + + const result = (await session.client.request( + "callHierarchy/incomingCalls", + { item: lspItem }, + )) as Array<{ + from: { + name: string; + kind: number; + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + selectionRange: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + }; + fromRanges: Array<{ + start: { line: number; character: number }; + end: { line: number; character: number }; + }>; + }> | null; + + if (!result) return null; + + return result + .map((call) => { + const fromPath = fileUriToAbsolutePath(call.from.uri); + if (!fromPath) return null; + return { + from: { + name: call.from.name, + kind: String(call.from.kind), + absolutePath: fromPath, + line: call.from.range.start.line + 1, + column: call.from.range.start.character + 1, + endLine: call.from.range.end.line + 1, + endColumn: call.from.range.end.character + 1, + selectionLine: call.from.selectionRange.start.line + 1, + selectionColumn: call.from.selectionRange.start.character + 1, + selectionEndLine: call.from.selectionRange.end.line + 1, + selectionEndColumn: call.from.selectionRange.end.character + 1, + }, + fromRanges: call.fromRanges.map((r) => ({ + line: r.start.line + 1, + column: r.start.character + 1, + endLine: r.end.line + 1, + endColumn: r.end.character + 1, + })), + }; + }) + .filter((call): call is LanguageServiceIncomingCall => call !== null); + } catch { + return null; + } + } + private async ensureSession( workspaceId: string, workspacePath: string, diff --git a/apps/desktop/src/main/lib/language-services/manager.ts b/apps/desktop/src/main/lib/language-services/manager.ts index 4bec13e01d0..04d88ebd699 100644 --- a/apps/desktop/src/main/lib/language-services/manager.ts +++ b/apps/desktop/src/main/lib/language-services/manager.ts @@ -12,7 +12,10 @@ import { TomlLanguageProvider } from "./providers/toml/TomlLanguageProvider"; import { TypeScriptLanguageProvider } from "./providers/typescript/TypeScriptLanguageProvider"; import { YamlLanguageProvider } from "./providers/yaml/YamlLanguageProvider"; import type { + LanguageServiceCallHierarchyItem, LanguageServiceDocument, + LanguageServiceIncomingCall, + LanguageServiceLocation, LanguageServiceProvider, LanguageServiceProviderDescriptor, LanguageServiceWorkspaceSnapshot, @@ -179,6 +182,47 @@ export class LanguageServiceManager { return languageDiagnosticsStore.subscribe(workspaceId, listener); } + async findReferences(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + line: number; + column: number; + }): Promise { + const provider = this.resolveProvider(args.languageId); + if (!provider || !this.isProviderEnabled(provider.id)) return null; + return (await provider.findReferences?.(args)) ?? null; + } + + async prepareCallHierarchy(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + line: number; + column: number; + }): Promise { + const provider = this.resolveProvider(args.languageId); + if (!provider || !this.isProviderEnabled(provider.id)) return null; + return (await provider.prepareCallHierarchy?.(args)) ?? null; + } + + async getIncomingCalls(args: { + workspaceId: string; + languageId: string; + item: LanguageServiceCallHierarchyItem; + }): Promise { + const provider = this.resolveProvider(args.languageId); + if (!provider || !this.isProviderEnabled(provider.id)) return null; + return ( + (await provider.getIncomingCalls?.({ + workspaceId: args.workspaceId, + item: args.item, + })) ?? null + ); + } + private isProviderEnabled(providerId: string): boolean { return this.enabledProviders.get(providerId) ?? false; } diff --git a/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts index 422574d7fab..6a1051b71d3 100644 --- a/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts +++ b/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts @@ -4,8 +4,11 @@ import { createRequire } from "node:module"; import path from "node:path"; import { languageDiagnosticsStore } from "../../diagnostics-store"; import type { + LanguageServiceCallHierarchyItem, LanguageServiceDiagnostic, LanguageServiceDocument, + LanguageServiceIncomingCall, + LanguageServiceLocation, LanguageServiceProvider, LanguageServiceProviderSummary, LanguageServiceRelatedInformation, @@ -393,6 +396,168 @@ export class TypeScriptLanguageProvider implements LanguageServiceProvider { this.sessions.delete(args.workspaceId); } + async findReferences(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + line: number; + column: number; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) return null; + + try { + const response = await this.sendRequest(session, "references", { + file: args.absolutePath, + line: args.line, + offset: args.column, + }); + + const refs = response.body as + | { + refs?: Array<{ + file: string; + start: { line: number; offset: number }; + end: { line: number; offset: number }; + }>; + } + | undefined; + + if (!refs?.refs) return null; + + return refs.refs.map((ref) => ({ + absolutePath: ref.file, + line: ref.start.line, + column: ref.start.offset, + endLine: ref.end.line, + endColumn: ref.end.offset, + })); + } catch { + return null; + } + } + + async prepareCallHierarchy(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + line: number; + column: number; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) return null; + + try { + const response = await this.sendRequest(session, "prepareCallHierarchy", { + file: args.absolutePath, + line: args.line, + offset: args.column, + }); + + const items = response.body as + | Array<{ + name: string; + kind: string; + file: string; + span: { + start: { line: number; offset: number }; + end: { line: number; offset: number }; + }; + selectionSpan: { + start: { line: number; offset: number }; + end: { line: number; offset: number }; + }; + }> + | undefined; + + if (!items) return null; + + return items.map((item) => ({ + name: item.name, + kind: item.kind, + absolutePath: item.file, + line: item.span.start.line, + column: item.span.start.offset, + endLine: item.span.end.line, + endColumn: item.span.end.offset, + selectionLine: item.selectionSpan.start.line, + selectionColumn: item.selectionSpan.start.offset, + selectionEndLine: item.selectionSpan.end.line, + selectionEndColumn: item.selectionSpan.end.offset, + })); + } catch { + return null; + } + } + + async getIncomingCalls(args: { + workspaceId: string; + item: LanguageServiceCallHierarchyItem; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) return null; + + try { + const response = await this.sendRequest( + session, + "provideCallHierarchyIncomingCalls", + { + file: args.item.absolutePath, + line: args.item.selectionLine, + offset: args.item.selectionColumn, + }, + ); + + const calls = response.body as + | Array<{ + from: { + name: string; + kind: string; + file: string; + span: { + start: { line: number; offset: number }; + end: { line: number; offset: number }; + }; + selectionSpan: { + start: { line: number; offset: number }; + end: { line: number; offset: number }; + }; + }; + fromSpans: Array<{ + start: { line: number; offset: number }; + end: { line: number; offset: number }; + }>; + }> + | undefined; + + if (!calls) return null; + + return calls.map((call) => ({ + from: { + name: call.from.name, + kind: call.from.kind, + absolutePath: call.from.file, + line: call.from.span.start.line, + column: call.from.span.start.offset, + endLine: call.from.span.end.line, + endColumn: call.from.span.end.offset, + selectionLine: call.from.selectionSpan.start.line, + selectionColumn: call.from.selectionSpan.start.offset, + selectionEndLine: call.from.selectionSpan.end.line, + selectionEndColumn: call.from.selectionSpan.end.offset, + }, + fromRanges: call.fromSpans.map((span) => ({ + line: span.start.line, + column: span.start.offset, + endLine: span.end.line, + endColumn: span.end.offset, + })), + })); + } catch { + return null; + } + } + private async ensureSession( workspaceId: string, workspacePath: string, diff --git a/apps/desktop/src/main/lib/language-services/types.ts b/apps/desktop/src/main/lib/language-services/types.ts index df3aeb7cffe..74f1d0cef80 100644 --- a/apps/desktop/src/main/lib/language-services/types.ts +++ b/apps/desktop/src/main/lib/language-services/types.ts @@ -66,6 +66,47 @@ export interface LanguageServiceWorkspaceSnapshot { }; } +/** + * Location of a symbol reference returned by findReferences / call hierarchy. + */ +export interface LanguageServiceLocation { + absolutePath: string; + line: number; + column: number; + endLine: number; + endColumn: number; +} + +/** + * A call hierarchy item returned by prepareCallHierarchy. + */ +export interface LanguageServiceCallHierarchyItem { + name: string; + kind: string; + absolutePath: string; + line: number; + column: number; + endLine: number; + endColumn: number; + selectionLine: number; + selectionColumn: number; + selectionEndLine: number; + selectionEndColumn: number; +} + +/** + * An incoming call hierarchy entry. + */ +export interface LanguageServiceIncomingCall { + from: LanguageServiceCallHierarchyItem; + fromRanges: Array<{ + line: number; + column: number; + endLine: number; + endColumn: number; + }>; +} + export interface LanguageServiceProvider { readonly id: string; readonly label: string; @@ -93,4 +134,36 @@ export interface LanguageServiceProvider { workspaceId: string; workspacePath: string; }): Promise; + + /** + * Find all references to a symbol at the given position. + * Returns null if the provider does not support this operation. + */ + findReferences?(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + line: number; + column: number; + }): Promise; + + /** + * Prepare call hierarchy at the given position. + * Returns null if the provider does not support this operation. + */ + prepareCallHierarchy?(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + line: number; + column: number; + }): Promise; + + /** + * Get incoming calls for a call hierarchy item. + */ + getIncomingCalls?(args: { + workspaceId: string; + item: LanguageServiceCallHierarchyItem; + }): Promise; } diff --git a/apps/desktop/src/main/lib/reference-graph/graph-builder.ts b/apps/desktop/src/main/lib/reference-graph/graph-builder.ts new file mode 100644 index 00000000000..454568b9967 --- /dev/null +++ b/apps/desktop/src/main/lib/reference-graph/graph-builder.ts @@ -0,0 +1,393 @@ +import fs from "node:fs"; +import path from "node:path"; +import { languageServiceManager } from "../language-services/manager"; +import type { + LanguageServiceCallHierarchyItem, + LanguageServiceLocation, +} from "../language-services/types"; +import { toRelativeWorkspacePath } from "../language-services/utils"; +import type { + ReferenceGraphData, + ReferenceGraphEdge, + ReferenceGraphNode, + ReferenceGraphRequest, +} from "./types"; + +const CONTEXT_LINES = 3; + +function makeNodeId(absolutePath: string, line: number, column: number) { + return `${absolutePath}:${line}:${column}`; +} + +function getLanguageIdFromPath(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const map: Record = { + ".ts": "typescript", + ".tsx": "typescriptreact", + ".js": "javascript", + ".jsx": "javascriptreact", + ".py": "python", + ".go": "go", + ".rs": "rust", + ".json": "json", + ".html": "html", + ".css": "css", + ".yaml": "yaml", + ".yml": "yaml", + ".md": "markdown", + ".dart": "dart", + ".graphql": "graphql", + ".gql": "graphql", + ".toml": "toml", + ".sql": "sql", + ".sh": "shellscript", + ".bash": "shellscript", + ".java": "java", + ".kt": "kotlin", + ".rb": "ruby", + ".php": "php", + ".swift": "swift", + ".c": "c", + ".cpp": "cpp", + ".h": "c", + ".hpp": "cpp", + }; + return map[ext] ?? "plaintext"; +} + +function getCodeSnippet( + absolutePath: string, + line: number, + endLine: number, +): { snippet: string; startLine: number } | null { + try { + const content = fs.readFileSync(absolutePath, "utf8"); + const lines = content.split("\n"); + const startLine = Math.max(0, line - 1 - CONTEXT_LINES); + const finalLine = Math.min(lines.length, endLine + CONTEXT_LINES); + const snippet = lines.slice(startLine, finalLine).join("\n"); + return { snippet, startLine: startLine + 1 }; + } catch { + return null; + } +} + +function shouldExclude( + absolutePath: string, + workspacePath: string, + excludePatterns: string[], +): boolean { + const relative = path.relative(workspacePath, absolutePath); + for (const pattern of excludePatterns) { + // Simple glob check — support ** / node_modules patterns + if (pattern.includes("node_modules") && relative.includes("node_modules")) { + return true; + } + if (pattern.includes("dist") && relative.includes("/dist/")) { + return true; + } + if (pattern.includes(".git") && relative.includes("/.git/")) { + return true; + } + } + return false; +} + +export async function buildReferenceGraph( + request: ReferenceGraphRequest, +): Promise { + const maxDepth = request.maxDepth ?? 3; + const maxNodes = request.maxNodes ?? 100; + const excludePatterns = request.excludePatterns ?? [ + "**/node_modules/**", + "**/dist/**", + "**/.git/**", + ]; + + const nodes = new Map(); + const edges = new Map(); + + // Try call hierarchy first (works for functions/methods) + const callHierarchyItems = await languageServiceManager.prepareCallHierarchy({ + workspaceId: request.workspaceId, + workspacePath: request.workspacePath, + absolutePath: request.absolutePath, + languageId: request.languageId, + line: request.line, + column: request.column, + }); + + if (callHierarchyItems && callHierarchyItems.length > 0) { + // Build from call hierarchy + const rootItem = callHierarchyItems[0]; + const rootNodeId = makeNodeId( + rootItem.absolutePath, + rootItem.line, + rootItem.column, + ); + addNodeFromCallHierarchyItem( + nodes, + rootItem, + rootNodeId, + request.workspacePath, + true, + 0, + ); + + await buildCallHierarchyGraph( + request, + rootItem, + rootNodeId, + nodes, + edges, + 1, + maxDepth, + maxNodes, + excludePatterns, + ); + } else { + // Fall back to references + const rootNodeId = makeNodeId( + request.absolutePath, + request.line, + request.column, + ); + const snippet = getCodeSnippet( + request.absolutePath, + request.line, + request.line, + ); + nodes.set(rootNodeId, { + id: rootNodeId, + name: "Symbol", + kind: "unknown", + absolutePath: request.absolutePath, + relativePath: toRelativeWorkspacePath( + request.workspacePath, + request.absolutePath, + ), + line: request.line, + column: request.column, + endLine: request.line, + endColumn: request.column, + codeSnippet: snippet?.snippet ?? "", + languageId: getLanguageIdFromPath(request.absolutePath), + snippetStartLine: snippet?.startLine ?? request.line, + isRoot: true, + depth: 0, + }); + + await buildReferencesGraph( + request, + rootNodeId, + nodes, + edges, + 1, + maxDepth, + maxNodes, + excludePatterns, + ); + } + + return { + nodes: Array.from(nodes.values()), + edges: Array.from(edges.values()), + }; +} + +async function buildCallHierarchyGraph( + request: ReferenceGraphRequest, + item: LanguageServiceCallHierarchyItem, + nodeId: string, + nodes: Map, + edges: Map, + currentDepth: number, + maxDepth: number, + maxNodes: number, + excludePatterns: string[], +): Promise { + if (currentDepth > maxDepth || nodes.size >= maxNodes) return; + + const incomingCalls = await languageServiceManager.getIncomingCalls({ + workspaceId: request.workspaceId, + languageId: request.languageId, + item, + }); + + if (!incomingCalls) return; + + const pendingItems: Array<{ + item: LanguageServiceCallHierarchyItem; + nodeId: string; + }> = []; + + for (const call of incomingCalls) { + if (nodes.size >= maxNodes) break; + if ( + shouldExclude( + call.from.absolutePath, + request.workspacePath, + excludePatterns, + ) + ) + continue; + + const callerNodeId = makeNodeId( + call.from.absolutePath, + call.from.line, + call.from.column, + ); + + if (!nodes.has(callerNodeId)) { + addNodeFromCallHierarchyItem( + nodes, + call.from, + callerNodeId, + request.workspacePath, + false, + currentDepth, + ); + pendingItems.push({ item: call.from, nodeId: callerNodeId }); + } + + const edgeId = `${callerNodeId}->${nodeId}`; + if (!edges.has(edgeId)) { + edges.set(edgeId, { + id: edgeId, + source: callerNodeId, + target: nodeId, + }); + } + } + + // Recurse into callers + await Promise.all( + pendingItems.map(({ item, nodeId: callerId }) => + buildCallHierarchyGraph( + request, + item, + callerId, + nodes, + edges, + currentDepth + 1, + maxDepth, + maxNodes, + excludePatterns, + ), + ), + ); +} + +async function buildReferencesGraph( + request: ReferenceGraphRequest, + rootNodeId: string, + nodes: Map, + edges: Map, + currentDepth: number, + maxDepth: number, + maxNodes: number, + excludePatterns: string[], +): Promise { + if (currentDepth > maxDepth || nodes.size >= maxNodes) return; + + const rootNode = nodes.get(rootNodeId); + if (!rootNode) return; + + const references = await languageServiceManager.findReferences({ + workspaceId: request.workspaceId, + workspacePath: request.workspacePath, + absolutePath: rootNode.absolutePath, + languageId: request.languageId, + line: rootNode.line, + column: rootNode.column, + }); + + if (!references) return; + + for (const ref of references) { + if (nodes.size >= maxNodes) break; + if (shouldExclude(ref.absolutePath, request.workspacePath, excludePatterns)) + continue; + + const refNodeId = makeNodeId(ref.absolutePath, ref.line, ref.column); + + // Skip self-references + if (refNodeId === rootNodeId) continue; + + if (!nodes.has(refNodeId)) { + addNodeFromLocation( + nodes, + ref, + refNodeId, + request.workspacePath, + currentDepth, + ); + } + + const edgeId = `${rootNodeId}->${refNodeId}`; + if (!edges.has(edgeId)) { + edges.set(edgeId, { + id: edgeId, + source: rootNodeId, + target: refNodeId, + }); + } + } +} + +function addNodeFromCallHierarchyItem( + nodes: Map, + item: LanguageServiceCallHierarchyItem, + nodeId: string, + workspacePath: string, + isRoot: boolean, + depth: number, +): void { + const snippet = getCodeSnippet(item.absolutePath, item.line, item.endLine); + nodes.set(nodeId, { + id: nodeId, + name: item.name, + kind: item.kind, + absolutePath: item.absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, item.absolutePath), + line: item.line, + column: item.column, + endLine: item.endLine, + endColumn: item.endColumn, + codeSnippet: snippet?.snippet ?? "", + languageId: getLanguageIdFromPath(item.absolutePath), + snippetStartLine: snippet?.startLine ?? item.line, + isRoot, + depth, + }); +} + +function addNodeFromLocation( + nodes: Map, + location: LanguageServiceLocation, + nodeId: string, + workspacePath: string, + depth: number, +): void { + const snippet = getCodeSnippet( + location.absolutePath, + location.line, + location.endLine, + ); + nodes.set(nodeId, { + id: nodeId, + name: path.basename(location.absolutePath), + kind: "reference", + absolutePath: location.absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, location.absolutePath), + line: location.line, + column: location.column, + endLine: location.endLine, + endColumn: location.endColumn, + codeSnippet: snippet?.snippet ?? "", + languageId: getLanguageIdFromPath(location.absolutePath), + snippetStartLine: snippet?.startLine ?? location.line, + isRoot: false, + depth, + }); +} diff --git a/apps/desktop/src/main/lib/reference-graph/index.ts b/apps/desktop/src/main/lib/reference-graph/index.ts new file mode 100644 index 00000000000..7db1186824d --- /dev/null +++ b/apps/desktop/src/main/lib/reference-graph/index.ts @@ -0,0 +1,7 @@ +export { buildReferenceGraph } from "./graph-builder"; +export type { + ReferenceGraphData, + ReferenceGraphEdge, + ReferenceGraphNode, + ReferenceGraphRequest, +} from "./types"; diff --git a/apps/desktop/src/main/lib/reference-graph/types.ts b/apps/desktop/src/main/lib/reference-graph/types.ts new file mode 100644 index 00000000000..2b75d17f35a --- /dev/null +++ b/apps/desktop/src/main/lib/reference-graph/types.ts @@ -0,0 +1,53 @@ +/** + * Types for the reference graph feature. + * Shared between main process (graph building) and renderer (visualization). + */ + +export interface ReferenceGraphNode { + id: string; + name: string; + /** Symbol kind (function, class, variable, etc.) */ + kind: string; + absolutePath: string; + relativePath: string | null; + line: number; + column: number; + endLine: number; + endColumn: number; + /** Code snippet with context lines */ + codeSnippet: string; + /** Language ID for syntax highlighting */ + languageId: string; + /** Starting line number of the snippet in the file */ + snippetStartLine: number; + /** Whether this is the root node (the queried symbol) */ + isRoot: boolean; + /** Depth in the graph from root */ + depth: number; +} + +export interface ReferenceGraphEdge { + id: string; + source: string; + target: string; +} + +export interface ReferenceGraphData { + nodes: ReferenceGraphNode[]; + edges: ReferenceGraphEdge[]; +} + +export interface ReferenceGraphRequest { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + line: number; + column: number; + /** Max recursion depth (default 3) */ + maxDepth?: number; + /** Max total nodes (default 100) */ + maxNodes?: number; + /** Glob patterns to exclude */ + excludePatterns?: string[]; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceGraphPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceGraphPane.tsx new file mode 100644 index 00000000000..e34b73f80b8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceGraphPane.tsx @@ -0,0 +1,288 @@ +import { + Background, + Controls, + type Edge, + type Node, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, + useReactFlow, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import ELK from "elkjs/lib/elk.bundled.js"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { MosaicBranch } from "react-mosaic-component"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { BasePaneWindow, PaneToolbarActions } from "../components"; +import { ReferenceNode } from "./ReferenceNode"; + +const elk = new ELK(); + +interface ReferenceGraphPaneProps { + paneId: string; + path: MosaicBranch[]; + tabId: string; + workspaceId: string; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; + onPopOut?: () => void; +} + +const NODE_WIDTH = 350; +const NODE_HEIGHT = 200; + +const nodeTypes = { referenceNode: ReferenceNode }; + +async function layoutGraph( + nodes: Node[], + edges: Edge[], +): Promise<{ nodes: Node[]; edges: Edge[] }> { + const graph = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.layered.spacing.nodeNodeBetweenLayers": "80", + "elk.spacing.nodeNode": "40", + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + }, + children: nodes.map((n) => ({ + id: n.id, + width: NODE_WIDTH, + height: NODE_HEIGHT, + })), + edges: edges.map((e) => ({ + id: e.id, + sources: [e.source], + targets: [e.target], + })), + }; + + const layoutResult = await elk.layout(graph); + + const layoutedNodes = nodes.map((node) => { + const elkNode = layoutResult.children?.find((n) => n.id === node.id); + return { + ...node, + position: { + x: elkNode?.x ?? 0, + y: elkNode?.y ?? 0, + }, + }; + }); + + return { nodes: layoutedNodes, edges }; +} + +function ReferenceGraphInner({ + paneId, + path, + tabId, + workspaceId, + splitPaneAuto, + removePane, + setFocusedPane, + onPopOut, +}: ReferenceGraphPaneProps) { + const pane = useTabsStore((s) => s.panes[paneId]); + const refGraphState = pane?.referenceGraph; + const { fitView } = useReactFlow(); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [maxDepth, setMaxDepth] = useState(3); + + const buildGraphMutation = + electronTrpc.referenceGraph.buildGraph.useMutation(); + + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + + const handleNodeDoubleClick = useCallback( + (absolutePath: string, line: number) => { + addFileViewerPane(workspaceId, { + filePath: absolutePath, + line, + isPinned: false, + }); + }, + [addFileViewerPane, workspaceId], + ); + + const loadGraph = useCallback(async () => { + if (!refGraphState) return; + + setIsLoading(true); + setError(null); + + try { + const graph = await buildGraphMutation.mutateAsync({ + workspaceId, + absolutePath: refGraphState.absolutePath, + languageId: refGraphState.languageId, + line: refGraphState.line, + column: refGraphState.column, + maxDepth, + }); + + const flowNodes: Node[] = graph.nodes.map((n) => ({ + id: n.id, + type: "referenceNode", + position: { x: 0, y: 0 }, + data: { + ...n, + onDoubleClick: handleNodeDoubleClick, + }, + })); + + const flowEdges: Edge[] = graph.edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + animated: true, + style: { stroke: "var(--muted-foreground)", strokeWidth: 1.5 }, + })); + + if (flowNodes.length > 0) { + const layouted = await layoutGraph(flowNodes, flowEdges); + setNodes(layouted.nodes); + setEdges(layouted.edges); + setTimeout(() => fitView({ padding: 0.2 }), 50); + } else { + setNodes([]); + setEdges([]); + setError("No references found for this symbol."); + } + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to build reference graph", + ); + } finally { + setIsLoading(false); + } + }, [ + refGraphState, + workspaceId, + maxDepth, + buildGraphMutation, + handleNodeDoubleClick, + setNodes, + setEdges, + fitView, + ]); + + // Load graph on mount or when params change + useEffect(() => { + void loadGraph(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadGraph]); + + const depthOptions = useMemo( + () => [1, 2, 3, 4, 5].map((d) => ({ value: d, label: `Depth: ${d}` })), + [], + ); + + return ( + ( +
+ + Reference Graph + + {refGraphState && ( + + {refGraphState.absolutePath.split("/").pop()}:{refGraphState.line} + + )} +
+ + +
+ +
+ )} + > +
+ {isLoading && nodes.length === 0 && ( +
+
+ Building reference graph... +
+
+ )} + {error && nodes.length === 0 && ( +
+
{error}
+
+ )} + + + + +
+
+ ); +} + +export function ReferenceGraphPane(props: ReferenceGraphPaneProps) { + return ( + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceNode.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceNode.tsx new file mode 100644 index 00000000000..5fcf6c8ab16 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceNode.tsx @@ -0,0 +1,107 @@ +import { Handle, Position } from "@xyflow/react"; +import { memo, useCallback } from "react"; + +interface ReferenceNodeData { + name: string; + kind: string; + relativePath: string | null; + absolutePath: string; + line: number; + codeSnippet: string; + languageId: string; + snippetStartLine: number; + isRoot: boolean; + depth: number; + onDoubleClick: (absolutePath: string, line: number) => void; +} + +const SYMBOL_ICONS: Record = { + function: "ƒ", + method: "m", + constructor: "C", + class: "◆", + interface: "I", + enum: "E", + variable: "v", + property: "p", + module: "M", + namespace: "N", + type: "T", + constant: "c", + reference: "→", + unknown: "?", + // tsserver symbol kinds (numeric) + "12": "ƒ", // function + "11": "m", // method + "5": "◆", // class + "8": "I", // interface + "10": "E", // enum + "13": "v", // variable + "6": "M", // module +}; + +function ReferenceNodeComponent({ data }: { data: ReferenceNodeData }) { + const handleDoubleClick = useCallback(() => { + data.onDoubleClick(data.absolutePath, data.line); + }, [data]); + + const icon = + SYMBOL_ICONS[data.kind.toLowerCase()] ?? SYMBOL_ICONS[data.kind] ?? "·"; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: ReactFlow node wrapper +
+ +
+ {icon} + + {data.name} + + {data.isRoot && ( + + ROOT + + )} +
+
+ + {data.relativePath ?? data.absolutePath}:{data.line} + +
+
+
+					{data.codeSnippet
+						.split("\n")
+						.slice(0, 8)
+						.map((codeLine, i) => {
+							const lineNum = data.snippetStartLine + i;
+							return (
+								
+ + {lineNum} + + {codeLine} +
+ ); + })} +
+
+ +
+ ); +} + +export const ReferenceNode = memo(ReferenceNodeComponent); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/index.ts new file mode 100644 index 00000000000..6f5182df211 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/index.ts @@ -0,0 +1 @@ +export { ReferenceGraphPane } from "./ReferenceGraphPane"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index ebd4e57fa4a..dcd75e5bc4c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -28,6 +28,7 @@ import { DatabaseExplorerPane } from "./DatabaseExplorerPane"; import { DevToolsPane } from "./DevToolsPane"; import { FileViewerPane } from "./FileViewerPane"; import { GitGraphPane } from "./GitGraphPane"; +import { ReferenceGraphPane } from "./ReferenceGraphPane"; import { TabPane } from "./TabPane"; import { VscodeExtensionPane } from "./VscodeExtensionPane"; @@ -161,6 +162,12 @@ export function TabView({ tab, isWorkspaceActive }: TabViewProps) { source?: "view" | "panel"; sessionId?: string; }; + referenceGraph?: { + absolutePath: string; + languageId: string; + line: number; + column: number; + }; } > = {}; for (const paneId of layoutPaneIds) { @@ -171,6 +178,7 @@ export function TabView({ tab, isWorkspaceActive }: TabViewProps) { type: pane.type, devtools: pane.devtools, vscodeExtension: pane.vscodeExtension, + referenceGraph: pane.referenceGraph, }; } } @@ -392,6 +400,22 @@ export function TabView({ tab, isWorkspaceActive }: TabViewProps) { ); } + // Route reference-graph panes + if (paneInfo.type === "reference-graph" && paneInfo.referenceGraph) { + return ( + handlePopOut(paneId)} + /> + ); + } + // Route vscode-extension panes if (paneInfo.type === "vscode-extension" && paneInfo.vscodeExtension) { return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/EditorContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/EditorContextMenu.tsx index b684a656a0c..e679df5cb7f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/EditorContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/EditorContextMenu.tsx @@ -11,6 +11,7 @@ import { LuClipboard, LuClipboardCopy, LuFile, + LuGitBranch, LuLink, LuMousePointerClick, LuScissors, @@ -32,6 +33,7 @@ export interface EditorActions { onCopySupersetLink?: () => void; onCopySupersetLinkWithLine?: () => void; onFind?: () => void; + onShowReferenceGraph?: () => void; } export type PaneActions = PaneContextMenuActions; @@ -63,6 +65,7 @@ export function EditorContextMenu({ onCopySupersetLink, onCopySupersetLinkWithLine, onFind, + onShowReferenceGraph, } = editorActions; const showCutPaste = !!onCut && !!onPaste; @@ -139,6 +142,16 @@ export function EditorContextMenu({ )} + {onShowReferenceGraph && ( + <> + + + + Show Reference Graph + + + )} + diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 8fa294ac1f0..0deb5ba7bf5 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -44,6 +44,7 @@ import { createFileViewerPane, createGitGraphTabWithPane, createPane, + createReferenceGraphTabWithPane, createTabWithPane, createVscodeExtensionTabWithPane, equalizeSplitPercentages, @@ -1920,6 +1921,52 @@ export const useTabsStore = create()( return { tabId: tab.id, paneId: pane.id }; }, + addReferenceGraphTab: ( + workspaceId: string, + absolutePath: string, + languageId: string, + line: number, + column: number, + ) => { + const state = get(); + + const { tab, pane } = createReferenceGraphTabWithPane( + workspaceId, + absolutePath, + languageId, + line, + column, + ); + + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = currentActiveId + ? [ + currentActiveId, + ...historyStack.filter((id) => id !== currentActiveId), + ] + : historyStack; + + set({ + tabs: [...state.tabs, tab], + panes: { ...state.panes, [pane.id]: pane }, + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: tab.id, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [tab.id]: pane.id, + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }); + + return { tabId: tab.id, paneId: pane.id }; + }, + addActionLogsTab: ( workspaceId: string, jobs: Array<{ diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 536e5cbedd0..de1b450c33e 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -226,6 +226,13 @@ export interface TabsStore extends TabsState { workspaceId: string, worktreePath: string, ) => { tabId: string; paneId: string }; + addReferenceGraphTab: ( + workspaceId: string, + absolutePath: string, + languageId: string, + line: number, + column: number, + ) => { tabId: string; paneId: string }; addActionLogsTab: ( workspaceId: string, jobs: Array<{ diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index e304c8f9be4..b92809b5255 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -21,6 +21,7 @@ import { type FileViewerMode, type FileViewerState, type GitGraphPaneState, + type ReferenceGraphPaneState, } from "shared/tabs-types"; import type { AddChatTabOptions, @@ -1096,3 +1097,55 @@ export const createVscodeExtensionTabWithPane = ( return { tab, pane }; }; + +export const createReferenceGraphPane = ( + tabId: string, + absolutePath: string, + languageId: string, + line: number, + column: number, +): Pane => { + const id = generateId("pane"); + const fileName = absolutePath.split("/").pop() ?? absolutePath; + const referenceGraph: ReferenceGraphPaneState = { + absolutePath, + languageId, + line, + column, + }; + return { + id, + tabId, + type: "reference-graph", + name: `References: ${fileName}:${line}`, + referenceGraph, + }; +}; + +export const createReferenceGraphTabWithPane = ( + workspaceId: string, + absolutePath: string, + languageId: string, + line: number, + column: number, +): { tab: Tab; pane: Pane } => { + const tabId = generateId("tab"); + const pane = createReferenceGraphPane( + tabId, + absolutePath, + languageId, + line, + column, + ); + const fileName = absolutePath.split("/").pop() ?? absolutePath; + + const tab: Tab = { + id: tabId, + name: `References: ${fileName}:${line}`, + workspaceId, + layout: pane.id, + createdAt: Date.now(), + }; + + return { tab, pane }; +}; diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index 10de190cf00..0bcfd953cc9 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -17,7 +17,8 @@ export type PaneType = | "git-graph" | "database-explorer" | "action-logs" - | "vscode-extension"; + | "vscode-extension" + | "reference-graph"; /** * Pane status for agent lifecycle indicators @@ -155,6 +156,7 @@ export interface Pane { source?: "view" | "panel"; sessionId?: string; }; + referenceGraph?: ReferenceGraphPaneState; workspaceRun?: { workspaceId: string; state: "running" | "stopped-by-user" | "stopped-by-exit"; @@ -264,6 +266,20 @@ export interface ActionLogsPaneState { runId?: number; } +/** + * Reference graph pane-specific properties + */ +export interface ReferenceGraphPaneState { + /** Absolute path of the file containing the symbol */ + absolutePath: string; + /** Language ID for the file */ + languageId: string; + /** Line of the symbol */ + line: number; + /** Column of the symbol */ + column: number; +} + /** * Base Tab interface - shared fields without layout */ diff --git a/bun.lock b/bun.lock index 20de4e2fa3e..b5c6e806d84 100644 --- a/bun.lock +++ b/bun.lock @@ -227,6 +227,7 @@ "@xterm/addon-webgl": "0.20.0-beta.194", "@xterm/headless": "6.1.0-beta.195", "@xterm/xterm": "6.1.0-beta.195", + "@xyflow/react": "^12.10.2", "ai": "^6.0.0", "ansi_up": "^6.0.6", "better-auth": "1.5.6", @@ -247,6 +248,7 @@ "dotenv": "^17.3.1", "drizzle-orm": "0.45.1", "electron-updater": "^6.7.3", + "elkjs": "^0.11.1", "exceljs": "^4.4.0", "execa": "^9.6.0", "express": "^5.1.0", @@ -3743,6 +3745,8 @@ "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], From bb3ffffa14444acf2cbea352304f885bdab47962 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 11 Apr 2026 14:00:23 +0900 Subject: [PATCH 2/4] feat(desktop): add Reference Graph on/off toggle in Editor Features settings - Add referenceGraphEnabled column to local-db settings schema - Add getReferenceGraph/setReferenceGraph tRPC procedures - Add ReferenceGraphSettings toggle component in Editor Features section - Add settings search entry for Reference Graph - Default: enabled (true) --- .../src/lib/trpc/routers/settings/index.ts | 26 ++++++++ .../utils/settings-search/settings-search.ts | 17 +++++ .../VscodeExtensionsSettings.tsx | 62 ++++++++++++++++++- packages/local-db/src/schema/schema.ts | 3 + 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 881c883365d..502604f2ace 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1104,6 +1104,32 @@ export const createSettingsRouter = () => { return { success: true }; }), + getReferenceGraph: publicProcedure.query(() => { + const row = getSettings(); + return { + enabled: row.referenceGraphEnabled ?? true, + }; + }), + + setReferenceGraph: publicProcedure + .input( + z.object({ + enabled: z.boolean(), + }), + ) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, referenceGraphEnabled: input.enabled }) + .onConflictDoUpdate({ + target: settings.id, + set: { referenceGraphEnabled: input.enabled }, + }) + .run(); + + return { success: true }; + }), + // TODO: remove telemetry procedures once telemetry_enabled column is dropped getTelemetryEnabled: publicProcedure.query(() => { return true; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 473322d58b0..aac06b268cb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -74,6 +74,7 @@ export const SETTING_ITEM_ID = { VSCODE_EXTENSIONS_MANAGE: "vscode-extensions-manage", VSCODE_EXTENSIONS_INDENT_RAINBOW: "vscode-extensions-indent-rainbow", VSCODE_EXTENSIONS_TRAILING_SPACES: "vscode-extensions-trailing-spaces", + VSCODE_EXTENSIONS_REFERENCE_GRAPH: "vscode-extensions-reference-graph", METRICS_GITHUB_OVERVIEW: "metrics-github-overview", METRICS_GITHUB_TRAFFIC: "metrics-github-traffic", @@ -1083,6 +1084,22 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "highlight", ], }, + { + id: SETTING_ITEM_ID.VSCODE_EXTENSIONS_REFERENCE_GRAPH, + section: "vscodeExtensions", + title: "Reference Graph", + description: + "Visualize code symbol references and call hierarchies as interactive graphs", + keywords: [ + "reference", + "graph", + "call", + "hierarchy", + "symbol", + "visualization", + "editor", + ], + }, { id: SETTING_ITEM_ID.METRICS_GITHUB_OVERVIEW, section: "metrics", diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx index 7a796ab74ba..319d6d6dca2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx @@ -240,8 +240,13 @@ export function VscodeExtensionsSettings({ SETTING_ITEM_ID.VSCODE_EXTENSIONS_TRAILING_SPACES, visibleItems, ); + const showReferenceGraph = isItemVisible( + SETTING_ITEM_ID.VSCODE_EXTENSIONS_REFERENCE_GRAPH, + visibleItems, + ); - const showEditorFeatures = showIndentRainbow || showTrailingSpaces; + const showEditorFeatures = + showIndentRainbow || showTrailingSpaces || showReferenceGraph; if (!showManage && !showEditorFeatures) return null; @@ -353,6 +358,7 @@ export function VscodeExtensionsSettings({
{showIndentRainbow && } {showTrailingSpaces && } + {showReferenceGraph && }
)} @@ -709,3 +715,57 @@ function TrailingSpacesSettings() { ); } + +function ReferenceGraphSettings() { + const { data } = electronTrpc.settings.getReferenceGraph.useQuery(undefined, { + staleTime: 30_000, + }); + const utils = electronTrpc.useUtils(); + const mutation = electronTrpc.settings.setReferenceGraph.useMutation({ + onSuccess: () => { + utils.settings.getReferenceGraph.invalidate(); + }, + }); + + const enabled = data?.enabled ?? true; + + const handleToggle = useCallback( + (checked: boolean) => { + mutation.mutate({ enabled: checked }); + }, + [mutation], + ); + + return ( +
+
+
+
+ +
+
+
+
+

Reference Graph

+ {enabled && ( + + Active + + )} +
+

+ Visualize code symbol references and call hierarchies as interactive + graphs. Available via right-click context menu in the code editor. +

+
+
+ +
+
+
+ ); +} diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 230f4e7664a..b4230633248 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -240,6 +240,9 @@ export const settings = sqliteTable("settings", { mode: "boolean", }), trailingSpacesColor: text("trailing_spaces_color"), + referenceGraphEnabled: integer("reference_graph_enabled", { + mode: "boolean", + }), exposeHostServiceViaRelay: integer("expose_host_service_via_relay", { mode: "boolean", }), From bcbe4530b232dd1ebcf81c200fb31795e7c04f8d Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 11 Apr 2026 14:12:22 +0900 Subject: [PATCH 3/4] fix(desktop): wire up reference graph context menu and fix critical issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Connect "Show Reference Graph" context menu to FileViewerPane via useEditorActions → FileEditorContextMenu → FileViewerContent chain - Gate context menu visibility on referenceGraphEnabled setting - Fix infinite re-render: stabilize mutateAsync via useRef instead of including unstable buildGraphMutation in useCallback deps - Add references/callHierarchy/documentSymbol to LSP client capabilities so non-TypeScript LSP servers enable these features - Use async fs.readFile instead of blocking readFileSync in graph-builder - Improve shouldExclude to match against path segments instead of naive string matching --- .../lsp/ExternalLspLanguageProvider.ts | 9 ++++ .../main/lib/reference-graph/graph-builder.ts | 49 ++++++++++--------- .../TabView/FileViewerPane/FileViewerPane.tsx | 20 ++++++++ .../FileEditorContextMenu.tsx | 3 ++ .../FileViewerContent/FileViewerContent.tsx | 3 ++ .../ReferenceGraphPane/ReferenceGraphPane.tsx | 8 +-- .../EditorContextMenu/useEditorActions.ts | 4 ++ 7 files changed, 70 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts index 3a9a29fde20..69ea5a54ad6 100644 --- a/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts +++ b/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts @@ -640,6 +640,15 @@ export class ExternalLspLanguageProvider implements LanguageServiceProvider { publishDiagnostics: { relatedInformation: true, }, + references: { + dynamicRegistration: false, + }, + callHierarchy: { + dynamicRegistration: false, + }, + documentSymbol: { + dynamicRegistration: false, + }, }, }, initializationOptions: this.resolveInitializationOptions({ diff --git a/apps/desktop/src/main/lib/reference-graph/graph-builder.ts b/apps/desktop/src/main/lib/reference-graph/graph-builder.ts index 454568b9967..d4a93a49daf 100644 --- a/apps/desktop/src/main/lib/reference-graph/graph-builder.ts +++ b/apps/desktop/src/main/lib/reference-graph/graph-builder.ts @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fs from "node:fs/promises"; import path from "node:path"; import { languageServiceManager } from "../language-services/manager"; import type { @@ -55,13 +55,13 @@ function getLanguageIdFromPath(filePath: string): string { return map[ext] ?? "plaintext"; } -function getCodeSnippet( +async function getCodeSnippet( absolutePath: string, line: number, endLine: number, -): { snippet: string; startLine: number } | null { +): Promise<{ snippet: string; startLine: number } | null> { try { - const content = fs.readFileSync(absolutePath, "utf8"); + const content = await fs.readFile(absolutePath, "utf8"); const lines = content.split("\n"); const startLine = Math.max(0, line - 1 - CONTEXT_LINES); const finalLine = Math.min(lines.length, endLine + CONTEXT_LINES); @@ -72,21 +72,22 @@ function getCodeSnippet( } } +/** + * Check if a file path should be excluded from the graph. + * Patterns are matched against path segments — e.g. "node_modules" + * matches any path containing a "node_modules" directory segment. + */ function shouldExclude( absolutePath: string, workspacePath: string, excludePatterns: string[], ): boolean { const relative = path.relative(workspacePath, absolutePath); + const segments = relative.split(path.sep); for (const pattern of excludePatterns) { - // Simple glob check — support ** / node_modules patterns - if (pattern.includes("node_modules") && relative.includes("node_modules")) { - return true; - } - if (pattern.includes("dist") && relative.includes("/dist/")) { - return true; - } - if (pattern.includes(".git") && relative.includes("/.git/")) { + // Extract the directory name from glob patterns like "**/node_modules/**" + const dirName = pattern.replace(/\*\*\//g, "").replace(/\/\*\*/g, ""); + if (segments.includes(dirName)) { return true; } } @@ -125,7 +126,7 @@ export async function buildReferenceGraph( rootItem.line, rootItem.column, ); - addNodeFromCallHierarchyItem( + await addNodeFromCallHierarchyItem( nodes, rootItem, rootNodeId, @@ -152,7 +153,7 @@ export async function buildReferenceGraph( request.line, request.column, ); - const snippet = getCodeSnippet( + const snippet = await getCodeSnippet( request.absolutePath, request.line, request.line, @@ -239,7 +240,7 @@ async function buildCallHierarchyGraph( ); if (!nodes.has(callerNodeId)) { - addNodeFromCallHierarchyItem( + await addNodeFromCallHierarchyItem( nodes, call.from, callerNodeId, @@ -315,7 +316,7 @@ async function buildReferencesGraph( if (refNodeId === rootNodeId) continue; if (!nodes.has(refNodeId)) { - addNodeFromLocation( + await addNodeFromLocation( nodes, ref, refNodeId, @@ -335,15 +336,19 @@ async function buildReferencesGraph( } } -function addNodeFromCallHierarchyItem( +async function addNodeFromCallHierarchyItem( nodes: Map, item: LanguageServiceCallHierarchyItem, nodeId: string, workspacePath: string, isRoot: boolean, depth: number, -): void { - const snippet = getCodeSnippet(item.absolutePath, item.line, item.endLine); +): Promise { + const snippet = await getCodeSnippet( + item.absolutePath, + item.line, + item.endLine, + ); nodes.set(nodeId, { id: nodeId, name: item.name, @@ -362,14 +367,14 @@ function addNodeFromCallHierarchyItem( }); } -function addNodeFromLocation( +async function addNodeFromLocation( nodes: Map, location: LanguageServiceLocation, nodeId: string, workspacePath: string, depth: number, -): void { - const snippet = getCodeSnippet( +): Promise { + const snippet = await getCodeSnippet( location.absolutePath, location.line, location.endLine, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index 658dccb1137..cb016e0390d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -45,6 +45,7 @@ import { retargetAbsolutePath, toAbsoluteWorkspacePath, } from "shared/absolute-paths"; +import { detectLanguage } from "shared/detect-language"; import { isHtmlFile, isImageFile, isMarkdownFile } from "shared/file-types"; import type { FileViewerMode } from "shared/tabs-types"; import type { CodeEditorAdapter } from "../../../components"; @@ -170,6 +171,13 @@ export function FileViewerPane({ const [htmlZoomLevel, setHtmlZoomLevel] = useState(0); const htmlPreviewRef = useRef(null); + const addReferenceGraphTab = useTabsStore((s) => s.addReferenceGraphTab); + const { data: referenceGraphSetting } = + electronTrpc.settings.getReferenceGraph.useQuery(undefined, { + staleTime: 30_000, + }); + const referenceGraphEnabled = referenceGraphSetting?.enabled ?? true; + const filePath = fileViewer?.filePath ?? ""; const viewMode = fileViewer?.viewMode ?? "raw"; const isPinned = fileViewer?.isPinned ?? false; @@ -179,6 +187,15 @@ export function FileViewerPane({ const initialLine = fileViewer?.initialLine; const initialColumn = fileViewer?.initialColumn; + const handleShowReferenceGraph = useCallback(() => { + if (!normalizedWorkspaceId || !filePath) return; + const editor = editorRef.current; + const selection = editor?.getSelectionLines(); + const line = selection?.startLine ?? 1; + const languageId = detectLanguage(filePath) ?? "typescript"; + addReferenceGraphTab(normalizedWorkspaceId, filePath, languageId, line, 1); + }, [normalizedWorkspaceId, filePath, addReferenceGraphTab]); + const documentKey = useMemo( () => buildEditorDocumentKey({ @@ -844,6 +861,9 @@ export function FileViewerPane({ markdownSearch={markdownSearch} htmlZoomLevel={htmlZoomLevel} htmlPreviewRef={htmlPreviewRef} + onShowReferenceGraph={ + referenceGraphEnabled ? handleShowReferenceGraph : undefined + } /> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileEditorContextMenu/FileEditorContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileEditorContextMenu/FileEditorContextMenu.tsx index 12fd8a07aad..3d9e175d073 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileEditorContextMenu/FileEditorContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileEditorContextMenu/FileEditorContextMenu.tsx @@ -24,6 +24,7 @@ interface FileEditorContextMenuProps { availableTabs: Tab[]; onMoveToTab: (tabId: string) => void; onMoveToNewTab: () => void; + onShowReferenceGraph?: () => void; } export function FileEditorContextMenu({ @@ -43,6 +44,7 @@ export function FileEditorContextMenu({ availableTabs, onMoveToTab, onMoveToNewTab, + onShowReferenceGraph, }: FileEditorContextMenuProps) { const getEditor = useCallback(() => editorRef.current, [editorRef]); @@ -53,6 +55,7 @@ export function FileEditorContextMenu({ worktreePath, supersetLinkProject, editable: true, + onShowReferenceGraph, }); return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx index fe8d00a9b3e..2902ea2f9bd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx @@ -232,6 +232,7 @@ interface FileViewerContentProps { markdownSearch: TextSearchState; htmlZoomLevel?: number; htmlPreviewRef?: MutableRefObject; + onShowReferenceGraph?: () => void; } export function FileViewerContent({ @@ -274,6 +275,7 @@ export function FileViewerContent({ markdownSearch, htmlZoomLevel = 0, htmlPreviewRef, + onShowReferenceGraph, }: FileViewerContentProps) { const isImage = isImageFile(filePath); const isHtml = isHtmlFile(filePath); @@ -778,6 +780,7 @@ export function FileViewerContent({ availableTabs={availableTabs} onMoveToTab={onMoveToTab} onMoveToNewTab={onMoveToNewTab} + onShowReferenceGraph={onShowReferenceGraph} >
s.addFileViewerPane); @@ -125,7 +127,7 @@ function ReferenceGraphInner({ setError(null); try { - const graph = await buildGraphMutation.mutateAsync({ + const graph = await mutateAsyncRef.current({ workspaceId, absolutePath: refGraphState.absolutePath, languageId: refGraphState.languageId, @@ -173,7 +175,6 @@ function ReferenceGraphInner({ refGraphState, workspaceId, maxDepth, - buildGraphMutation, handleNodeDoubleClick, setNodes, setEdges, @@ -183,7 +184,6 @@ function ReferenceGraphInner({ // Load graph on mount or when params change useEffect(() => { void loadGraph(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadGraph]); const depthOptions = useMemo( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/useEditorActions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/useEditorActions.ts index f13180eadde..44ed06a4ced 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/useEditorActions.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/useEditorActions.ts @@ -16,6 +16,8 @@ interface UseEditorActionsProps { supersetLinkProject?: SupersetLinkProject | null; /** If true, includes cut/paste actions (for editable editors) */ editable?: boolean; + /** Optional handler for "Show Reference Graph" context menu action */ + onShowReferenceGraph?: () => void; } /** @@ -29,6 +31,7 @@ export function useEditorActions({ worktreePath, supersetLinkProject, editable = true, + onShowReferenceGraph, }: UseEditorActionsProps): EditorActions { const { copyToClipboard } = useCopyToClipboard(); @@ -185,5 +188,6 @@ export function useEditorActions({ ? handleCopySupersetLinkWithLine : undefined, onFind: handleFind, + onShowReferenceGraph, }; } From cdc73b550449e0d993249ffc814dee52a9b46139 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 11 Apr 2026 14:26:31 +0900 Subject: [PATCH 4/4] fix(desktop): address all 10 review comments on reference graph PR P1 fixes: - Add drizzle migration for reference_graph_enabled column (0044) - Add "reference-graph" to paneSchema in ui-state router for tab persistence - Pass actual cursor column via new getCursorPosition() adapter method Major fixes: - Log errors to session.lastError instead of silently swallowing in findReferences/prepareCallHierarchy/getIncomingCalls catch blocks - Switch parallel Promise.all recursion to sequential loop to respect maxNodes budget in call hierarchy graph building - Add request generation tracking to prevent stale responses from overwriting current graph state - Clarify excludePatterns API contract as directory segment matching Minor fixes: - Use getPathBaseName() for cross-platform path handling in tab names - Use regex split for Windows path compatibility in toolbar display - Add path traversal check in tRPC router (absolutePath must be within workspace) --- .../lib/trpc/routers/reference-graph/index.ts | 13 + .../src/lib/trpc/routers/ui-state/index.ts | 9 + .../lsp/ExternalLspLanguageProvider.ts | 15 +- .../main/lib/reference-graph/graph-builder.ts | 31 +- .../src/main/lib/reference-graph/types.ts | 7 +- .../TabView/FileViewerPane/FileViewerPane.tsx | 13 +- .../ReferenceGraphPane/ReferenceGraphPane.tsx | 14 +- .../CodeEditorAdapter/CodeEditorAdapter.ts | 6 + .../components/CodeEditor/CodeEditor.tsx | 5 + .../desktop/src/renderer/stores/tabs/utils.ts | 4 +- ...44_add_reference_graph_enabled_setting.sql | 1 + .../local-db/drizzle/meta/0044_snapshot.json | 1522 +++++++++++++++++ packages/local-db/drizzle/meta/_journal.json | 9 +- 13 files changed, 1621 insertions(+), 28 deletions(-) create mode 100644 packages/local-db/drizzle/0044_add_reference_graph_enabled_setting.sql create mode 100644 packages/local-db/drizzle/meta/0044_snapshot.json diff --git a/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts b/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts index 7c0d96ea5b0..4fc5376d5ab 100644 --- a/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts +++ b/apps/desktop/src/lib/trpc/routers/reference-graph/index.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { TRPCError } from "@trpc/server"; import { buildReferenceGraph } from "main/lib/reference-graph"; import { z } from "zod"; @@ -43,6 +44,18 @@ export const createReferenceGraphRouter = () => { .mutation(async ({ input }) => { const workspacePath = resolveWorkspacePath(input.workspaceId); + // Ensure absolutePath is within the workspace (prevent path traversal) + const resolved = path.resolve(input.absolutePath); + if ( + !resolved.startsWith(workspacePath + path.sep) && + resolved !== workspacePath + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "absolutePath must be within the workspace", + }); + } + const graph = await buildReferenceGraph({ workspaceId: input.workspaceId, workspacePath, diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 11167cf6ba7..dd319b975d2 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -46,6 +46,7 @@ const paneSchema = z.object({ "database-explorer", "action-logs", "vscode-extension", + "reference-graph", ]), name: z.string(), isNew: z.boolean().optional(), @@ -125,6 +126,14 @@ const paneSchema = z.object({ worktreePath: z.string(), }) .optional(), + referenceGraph: z + .object({ + absolutePath: z.string(), + languageId: z.string(), + line: z.number(), + column: z.number(), + }) + .optional(), workspaceRun: z .object({ workspaceId: z.string(), diff --git a/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts index 69ea5a54ad6..02db14ec9c1 100644 --- a/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts +++ b/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts @@ -381,7 +381,10 @@ export class ExternalLspLanguageProvider implements LanguageServiceProvider { }; }) .filter((loc): loc is LanguageServiceLocation => loc !== null); - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + session.lastError = message; + this.workspaceErrors.set(args.workspaceId, message); return null; } } @@ -445,7 +448,10 @@ export class ExternalLspLanguageProvider implements LanguageServiceProvider { .filter( (item): item is LanguageServiceCallHierarchyItem => item !== null, ); - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + session.lastError = message; + this.workspaceErrors.set(args.workspaceId, message); return null; } } @@ -536,7 +542,10 @@ export class ExternalLspLanguageProvider implements LanguageServiceProvider { }; }) .filter((call): call is LanguageServiceIncomingCall => call !== null); - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + session.lastError = message; + this.workspaceErrors.set(args.workspaceId, message); return null; } } diff --git a/apps/desktop/src/main/lib/reference-graph/graph-builder.ts b/apps/desktop/src/main/lib/reference-graph/graph-builder.ts index d4a93a49daf..c5832e5d739 100644 --- a/apps/desktop/src/main/lib/reference-graph/graph-builder.ts +++ b/apps/desktop/src/main/lib/reference-graph/graph-builder.ts @@ -261,22 +261,21 @@ async function buildCallHierarchyGraph( } } - // Recurse into callers - await Promise.all( - pendingItems.map(({ item, nodeId: callerId }) => - buildCallHierarchyGraph( - request, - item, - callerId, - nodes, - edges, - currentDepth + 1, - maxDepth, - maxNodes, - excludePatterns, - ), - ), - ); + // Recurse into callers sequentially to respect maxNodes budget + for (const { item, nodeId: callerId } of pendingItems) { + if (nodes.size >= maxNodes) break; + await buildCallHierarchyGraph( + request, + item, + callerId, + nodes, + edges, + currentDepth + 1, + maxDepth, + maxNodes, + excludePatterns, + ); + } } async function buildReferencesGraph( diff --git a/apps/desktop/src/main/lib/reference-graph/types.ts b/apps/desktop/src/main/lib/reference-graph/types.ts index 2b75d17f35a..488307e66cd 100644 --- a/apps/desktop/src/main/lib/reference-graph/types.ts +++ b/apps/desktop/src/main/lib/reference-graph/types.ts @@ -48,6 +48,11 @@ export interface ReferenceGraphRequest { maxDepth?: number; /** Max total nodes (default 100) */ maxNodes?: number; - /** Glob patterns to exclude */ + /** + * Directory name segments to exclude from the graph. + * Glob-style patterns like "** /node_modules/**" are supported — the + * directory name is extracted and matched against path segments. + * Default: ["node_modules", "dist", ".git"] + */ excludePatterns?: string[]; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index cb016e0390d..a99a0d41d26 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -190,10 +190,17 @@ export function FileViewerPane({ const handleShowReferenceGraph = useCallback(() => { if (!normalizedWorkspaceId || !filePath) return; const editor = editorRef.current; - const selection = editor?.getSelectionLines(); - const line = selection?.startLine ?? 1; + const cursor = editor?.getCursorPosition(); + const line = cursor?.line ?? 1; + const column = cursor?.column ?? 1; const languageId = detectLanguage(filePath) ?? "typescript"; - addReferenceGraphTab(normalizedWorkspaceId, filePath, languageId, line, 1); + addReferenceGraphTab( + normalizedWorkspaceId, + filePath, + languageId, + line, + column, + ); }, [normalizedWorkspaceId, filePath, addReferenceGraphTab]); const documentKey = useMemo( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceGraphPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceGraphPane.tsx index 82b9df0438b..fc26816b6e5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceGraphPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ReferenceGraphPane/ReferenceGraphPane.tsx @@ -106,6 +106,7 @@ function ReferenceGraphInner({ electronTrpc.referenceGraph.buildGraph.useMutation(); const mutateAsyncRef = useRef(buildGraphMutation.mutateAsync); mutateAsyncRef.current = buildGraphMutation.mutateAsync; + const requestGenerationRef = useRef(0); const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); @@ -123,6 +124,7 @@ function ReferenceGraphInner({ const loadGraph = useCallback(async () => { if (!refGraphState) return; + const generation = ++requestGenerationRef.current; setIsLoading(true); setError(null); @@ -136,6 +138,9 @@ function ReferenceGraphInner({ maxDepth, }); + // Discard stale responses from earlier requests + if (generation !== requestGenerationRef.current) return; + const flowNodes: Node[] = graph.nodes.map((n) => ({ id: n.id, type: "referenceNode", @@ -156,6 +161,7 @@ function ReferenceGraphInner({ if (flowNodes.length > 0) { const layouted = await layoutGraph(flowNodes, flowEdges); + if (generation !== requestGenerationRef.current) return; setNodes(layouted.nodes); setEdges(layouted.edges); setTimeout(() => fitView({ padding: 0.2 }), 50); @@ -165,11 +171,14 @@ function ReferenceGraphInner({ setError("No references found for this symbol."); } } catch (err) { + if (generation !== requestGenerationRef.current) return; setError( err instanceof Error ? err.message : "Failed to build reference graph", ); } finally { - setIsLoading(false); + if (generation === requestGenerationRef.current) { + setIsLoading(false); + } } }, [ refGraphState, @@ -207,7 +216,8 @@ function ReferenceGraphInner({ {refGraphState && ( - {refGraphState.absolutePath.split("/").pop()}:{refGraphState.line} + {refGraphState.absolutePath.split(/[\\/]/).pop()}: + {refGraphState.line} )}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/CodeEditorAdapter/CodeEditorAdapter.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/CodeEditorAdapter/CodeEditorAdapter.ts index 17aea0f719c..1badd22b5dc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/CodeEditorAdapter/CodeEditorAdapter.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/CodeEditorAdapter/CodeEditorAdapter.ts @@ -3,12 +3,18 @@ export interface EditorSelectionLines { endLine: number; } +export interface EditorCursorPosition { + line: number; + column: number; +} + export interface CodeEditorAdapter { focus(): void; getValue(): string; setValue(value: string): void; revealPosition(line: number, column?: number): void; getSelectionLines(): EditorSelectionLines | null; + getCursorPosition(): EditorCursorPosition | null; selectAll(): void; cut(): void; copy(): void; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx index 7dcb3ff79a3..d1f98fdcf0f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx @@ -389,6 +389,11 @@ function createCodeMirrorAdapter( const endLine = view.state.doc.lineAt(selection.to).number; return { startLine, endLine }; }, + getCursorPosition() { + const cursor = view.state.selection.main.head; + const line = view.state.doc.lineAt(cursor); + return { line: line.number, column: cursor - line.from + 1 }; + }, selectAll() { selectAll(view); }, diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index b92809b5255..9ca0d5e0050 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1106,7 +1106,7 @@ export const createReferenceGraphPane = ( column: number, ): Pane => { const id = generateId("pane"); - const fileName = absolutePath.split("/").pop() ?? absolutePath; + const fileName = getPathBaseName(absolutePath); const referenceGraph: ReferenceGraphPaneState = { absolutePath, languageId, @@ -1137,7 +1137,7 @@ export const createReferenceGraphTabWithPane = ( line, column, ); - const fileName = absolutePath.split("/").pop() ?? absolutePath; + const fileName = getPathBaseName(absolutePath); const tab: Tab = { id: tabId, diff --git a/packages/local-db/drizzle/0044_add_reference_graph_enabled_setting.sql b/packages/local-db/drizzle/0044_add_reference_graph_enabled_setting.sql new file mode 100644 index 00000000000..ae2f6cd97d6 --- /dev/null +++ b/packages/local-db/drizzle/0044_add_reference_graph_enabled_setting.sql @@ -0,0 +1 @@ +ALTER TABLE `settings` ADD `reference_graph_enabled` integer; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0044_snapshot.json b/packages/local-db/drizzle/meta/0044_snapshot.json new file mode 100644 index 00000000000..1af4a75b4cc --- /dev/null +++ b/packages/local-db/drizzle/meta/0044_snapshot.json @@ -0,0 +1,1522 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "727b07cc-f66c-4301-94e4-0f02d3ecc3ca", + "prevId": "5a8d1776-f90b-4bf8-b6a0-c6142deecfc2", + "tables": { + "browser_history": { + "name": "browser_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_visited_at": { + "name": "last_visited_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visit_count": { + "name": "visit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "browser_history_url_unique": { + "name": "browser_history_url_unique", + "columns": [ + "url" + ], + "isUnique": true + }, + "browser_history_url_idx": { + "name": "browser_history_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "browser_history_last_visited_at_idx": { + "name": "browser_history_last_visited_at_idx", + "columns": [ + "last_visited_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_site_permissions": { + "name": "browser_site_permissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ask'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_site_permissions_origin_idx": { + "name": "browser_site_permissions_origin_idx", + "columns": [ + "origin" + ], + "isUnique": false + }, + "browser_site_permissions_origin_kind_unique": { + "name": "browser_site_permissions_origin_kind_unique", + "columns": [ + "origin", + "kind" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_base_branch": { + "name": "workspace_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_owner": { + "name": "github_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_image": { + "name": "hide_image", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_app": { + "name": "default_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_preset_overrides": { + "name": "agent_preset_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_custom_definitions": { + "name": "agent_custom_definitions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persist_terminal": { + "name": "persist_terminal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "auto_apply_default_preset": { + "name": "auto_apply_default_preset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_sounds_muted": { + "name": "notification_sounds_muted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_volume": { + "name": "notification_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prevent_agent_sleep": { + "name": "prevent_agent_sleep", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delete_local_branch": { + "name": "delete_local_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_open_mode": { + "name": "file_open_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_sidebar_open_view_width": { + "name": "right_sidebar_open_view_width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_presets_bar": { + "name": "show_presets_bar", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_compact_terminal_add_button": { + "name": "use_compact_terminal_add_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_family": { + "name": "terminal_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_size": { + "name": "terminal_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_family": { + "name": "editor_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_size": { + "name": "editor_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_resource_monitor": { + "name": "show_resource_monitor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_links_in_app": { + "name": "open_links_in_app", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_editor": { + "name": "default_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "indent_rainbow_enabled": { + "name": "indent_rainbow_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "indent_rainbow_colors": { + "name": "indent_rainbow_colors", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trailing_spaces_enabled": { + "name": "trailing_spaces_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trailing_spaces_color": { + "name": "trailing_spaces_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_graph_enabled": { + "name": "reference_graph_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expose_host_service_via_relay": { + "name": "expose_host_service_via_relay", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sections": { + "name": "workspace_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_collapsed": { + "name": "is_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_sections_project_id_idx": { + "name": "workspace_sections_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sections_project_id_projects_id_fk": { + "name": "workspace_sections_project_id_projects_id_fk", + "tableFrom": "workspace_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_unnamed": { + "name": "is_unnamed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "deleting_at": { + "name": "deleting_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_base": { + "name": "port_base", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + }, + "workspaces_section_id_idx": { + "name": "workspaces_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_section_id_workspace_sections_id_fk": { + "name": "workspaces_section_id_workspace_sections_id_fk", + "tableFrom": "workspaces", + "tableTo": "workspace_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_superset": { + "name": "created_by_superset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 556494f6d7d..6203d5f0a48 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1775765690176, "tag": "0043_add_expose_host_service_via_relay_setting", "breakpoints": true + }, + { + "idx": 44, + "version": "6", + "when": 1775884928353, + "tag": "0044_add_reference_graph_enabled_setting", + "breakpoints": true } ] -} +} \ No newline at end of file