From 4c5ff6e57bf43aa2366ba6753225170517a01807 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 17 Aug 2026 10:29:39 +0800 Subject: [PATCH 1/3] feat(windows): navigate Spring config, @Value, and beans Index the workspace with spring.index and resolve those locations before falling back to the language server, matching the macOS definition and reference order. --- docs/architecture/windows-development-plan.md | 3 +- .../features/bootstrap/use-app-bootstrap.ts | 2 + .../commands/navigation-command-actions.ts | 157 ++++++++++++++- .../features/spring/api/spring-index-api.ts | 83 ++++++++ .../features/spring/hooks/use-spring-index.ts | 109 +++++++++++ .../features/spring/stores/spring.store.ts | 57 ++++++ .../src/features/spring/types/spring.types.ts | 71 +++++++ .../spring/utils/spring-index-paths.ts | 31 +++ .../spring/utils/spring-navigation.test.ts | 142 ++++++++++++++ .../spring/utils/spring-navigation.ts | 183 ++++++++++++++++++ 10 files changed, 835 insertions(+), 3 deletions(-) create mode 100644 windows/tauri/src/features/spring/api/spring-index-api.ts create mode 100644 windows/tauri/src/features/spring/hooks/use-spring-index.ts create mode 100644 windows/tauri/src/features/spring/stores/spring.store.ts create mode 100644 windows/tauri/src/features/spring/types/spring.types.ts create mode 100644 windows/tauri/src/features/spring/utils/spring-index-paths.ts create mode 100644 windows/tauri/src/features/spring/utils/spring-navigation.test.ts create mode 100644 windows/tauri/src/features/spring/utils/spring-navigation.ts diff --git a/docs/architecture/windows-development-plan.md b/docs/architecture/windows-development-plan.md index cb1d723e7..510055b60 100644 --- a/docs/architecture/windows-development-plan.md +++ b/docs/architecture/windows-development-plan.md @@ -36,7 +36,8 @@ platform contract and enabled in the UI only when the capability exists. 2. Route workspace search, Local History, remaining non-Java LSP, Java/Maven, and run configurations through the same dispatcher. Built-in Java LSP now starts through the Windows host (`jdtls` + JDK discovery) and - `lsp.startServer` with `providerId: "java"`. + `lsp.startServer` with `providerId: "java"`. Spring configuration, `@Value`, + and bean-injection navigation uses `spring.index` before falling back to LSP. 3. Implement Windows-owned process, debug, update, and secure-storage flows in Rust where the current UI exposes them. 4. Hide or capability-gate future feature surfaces until their shared backend diff --git a/windows/tauri/src/features/bootstrap/use-app-bootstrap.ts b/windows/tauri/src/features/bootstrap/use-app-bootstrap.ts index 938f845a3..2b771aeea 100644 --- a/windows/tauri/src/features/bootstrap/use-app-bootstrap.ts +++ b/windows/tauri/src/features/bootstrap/use-app-bootstrap.ts @@ -11,6 +11,7 @@ import { } from "@/features/file-system/services/file-watcher-listener"; import { useOnboardingStore } from "@/features/onboarding/stores/onboarding.store"; import { useLspInitialization } from "@/features/editor/hooks/use-lsp-initialization"; +import { useSpringIndex } from "@/features/spring/hooks/use-spring-index"; import { useKeymapContext } from "@/features/keymaps/hooks/use-keymap-context"; import { useKeymaps } from "@/features/keymaps/hooks/use-keymaps"; import { useWhatsNewStore } from "@/features/settings/stores/whats-new.store"; @@ -42,6 +43,7 @@ export function useAppBootstrap() { useKeymaps(); useContextMenuPrevention(); useLspInitialization(); + useSpringIndex(); useEffect(() => { let timer: number | null = null; diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts index 8326ca91f..10256a3bb 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -10,7 +10,17 @@ import { } from "@/features/editor/utils/position"; import { useReferencesStore } from "@/features/references/stores/references.store"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { languageIdForEditorFile } from "@/features/editor/lsp/built-in-language-support"; +import { languageServerUnavailableMessage } from "@/features/editor/lsp/language-server-navigation"; +import { useLspStore } from "@/features/editor/lsp/stores/lsp.store"; +import { useSpringStore } from "@/features/spring/stores/spring.store"; +import type { SpringNavigationLocation } from "@/features/spring/types/spring.types"; +import { + resolveSpringDefinitions, + resolveSpringReferences, +} from "@/features/spring/utils/spring-navigation"; import { useUIState } from "@/features/window/stores/ui-state.store"; +import { getBaseName } from "@/utils/path-helpers"; import { showPromptDialog } from "@/ui/dialog"; import { toast } from "sonner"; @@ -40,6 +50,119 @@ type LspNavigationClient = { ) => Promise; }; +function activeEditorNavigationContext() { + const bufferStore = useBufferStore.getState(); + const activeBuffer = bufferStore.buffers.find((buffer) => buffer.id === bufferStore.activeBufferId); + const editorState = useEditorStateStore.getState(); + if (!activeBuffer || activeBuffer.type !== "editor" || !activeBuffer.path) return null; + return { bufferStore, activeBuffer, editorState }; +} + +function unavailableLanguageServerToast(filePath: string, lspClient: { hasSessionForFile(path: string): boolean }): string | null { + const status = useLspStore.getState().lspStatus; + return languageServerUnavailableMessage({ + languageId: languageIdForEditorFile(filePath), + status: status.status, + lastError: status.lastError, + hasSession: lspClient.hasSessionForFile(filePath), + }); +} + +function springLocationsForActiveFile(kind: "definition" | "references"): SpringNavigationLocation[] { + const context = activeEditorNavigationContext(); + const springState = useSpringStore.getState(); + if (!context || !springState.root) return []; + return kind === "definition" + ? resolveSpringDefinitions( + springState.index, + springState.root, + context.activeBuffer.path, + context.editorState.cursorPosition.line, + ) + : resolveSpringReferences( + springState.index, + springState.root, + context.activeBuffer.path, + context.editorState.cursorPosition.line, + ); +} + +async function navigateToSpringLocation(location: SpringNavigationLocation): Promise { + await goToActiveLspLocation( + "definition", + async () => [ + { + uri: location.filePath, + range: { + start: { line: location.line, character: location.column }, + end: { line: location.line, character: location.column }, + }, + }, + ], + { requireLanguageServer: false }, + ); +} + +async function presentSpringReferences( + locations: SpringNavigationLocation[], + symbol: string, +): Promise { + const context = activeEditorNavigationContext(); + if (!context) return; + + const [{ readFileContent }] = await Promise.all([ + import("@/features/file-system/controllers/file-operations"), + ]); + const referencesActions = useReferencesStore.getState().actions; + referencesActions.setIsLoading(true); + context.bufferStore.actions.openReferencesBuffer(); + + const lineContextCache = new Map>(); + const referenceLinesByFile = new Map>(); + for (const location of locations) { + const lineNumbers = referenceLinesByFile.get(location.filePath) ?? new Set(); + lineNumbers.add(location.line); + referenceLinesByFile.set(location.filePath, lineNumbers); + } + + const lineContextEntries = await Promise.all( + Array.from(referenceLinesByFile, async ([filePath, lineNumbers]) => { + let content = ""; + const buffer = context.bufferStore.buffers.find((candidate) => candidate.path === filePath); + if (buffer && "content" in buffer && typeof buffer.content === "string") { + content = buffer.content; + } else { + try { + content = await readFileContent(filePath); + } catch { + content = ""; + } + } + return [filePath, getLineTextsFromContent(content, lineNumbers)] as const; + }), + ); + for (const [filePath, lines] of lineContextEntries) { + lineContextCache.set(filePath, lines); + } + + referencesActions.setReferences( + { + symbol, + filePath: context.activeBuffer.path, + line: context.editorState.cursorPosition.line, + column: context.editorState.cursorPosition.column, + }, + locations.map((location) => ({ + filePath: location.filePath, + line: location.line, + column: location.column, + endLine: location.line, + endColumn: location.column, + lineContent: lineContextCache.get(location.filePath)?.get(location.line) || "", + })), + ); +} + async function goToActiveLspLocation( label: string, resolveLocations: ( @@ -48,6 +171,7 @@ async function goToActiveLspLocation( line: number, character: number, ) => Promise, + options: { requireLanguageServer?: boolean } = {}, ): Promise { const [{ LspClient }, { readFileContent }, { filePathFromUri }] = await Promise.all([ import("@/features/editor/lsp/lsp-client"), @@ -63,6 +187,14 @@ async function goToActiveLspLocation( if (!activeBuffer || activeBuffer.type !== "editor" || !activeBuffer.path) return; + if (options.requireLanguageServer !== false) { + const unavailable = unavailableLanguageServerToast(activeBuffer.path, lspClient); + if (unavailable) { + toast.error(unavailable); + return; + } + } + const locations = await resolveLocations( lspClient, activeBuffer.path, @@ -86,14 +218,14 @@ async function goToActiveLspLocation( }); const target = locations[0]; - const filePath = filePathFromUri(target.uri); + const filePath = target.uri.includes("://") ? filePathFromUri(target.uri) : target.uri; const existingBuffer = bufferStore.buffers.find((b) => b.path === filePath); if (existingBuffer) { bufferStore.actions.setActiveBuffer(existingBuffer.id); } else { const content = await readFileContent(filePath); - const fileName = filePath.split("/").pop() || "untitled"; + const fileName = getBaseName(filePath); const bufferId = bufferStore.actions.openBuffer(filePath, fileName, content); bufferStore.actions.setActiveBuffer(bufferId); } @@ -143,6 +275,15 @@ export function openOutlineSidebar(): void { } export async function goToDefinition(): Promise { + const springLocations = springLocationsForActiveFile("definition"); + if (springLocations.length === 1) { + await navigateToSpringLocation(springLocations[0]); + return; + } + if (springLocations.length > 1) { + await presentSpringReferences(springLocations, springLocations[0]?.symbol || "Spring"); + return; + } await goToActiveLspLocation("definition", (lspClient, filePath, line, character) => lspClient.getDefinition(filePath, line, character), ); @@ -174,6 +315,18 @@ export async function goToReferences(): Promise { if (!activeBuffer?.path) return; + const springLocations = springLocationsForActiveFile("references"); + if (springLocations.length > 0) { + await presentSpringReferences(springLocations, springLocations[0]?.symbol || "Spring"); + return; + } + + const unavailable = unavailableLanguageServerToast(activeBuffer.path, lspClient); + if (unavailable) { + toast.error(unavailable); + return; + } + const currentLine = getLineTextFromContent(editorAPI.getContent(), cursorPosition.line); const wordMatch = currentLine.slice(0, cursorPosition.column + 1).match(/[\w$]+$/); const wordEnd = currentLine.slice(cursorPosition.column).match(/^[\w$]*/); diff --git a/windows/tauri/src/features/spring/api/spring-index-api.ts b/windows/tauri/src/features/spring/api/spring-index-api.ts new file mode 100644 index 000000000..b9b141c05 --- /dev/null +++ b/windows/tauri/src/features/spring/api/spring-index-api.ts @@ -0,0 +1,83 @@ +import { executeCore, type CoreResponse } from "@/core/lithe-core-client"; +import type { SpringIndex } from "../types/spring.types"; + +interface CoreSpringIndex { + properties: Array<{ + name: string; + typeName?: string | null; + description?: string | null; + defaultValue?: string | null; + sourcePath?: string | null; + sourceLine?: number | null; + sourceColumn?: number | null; + }>; + values: Array<{ + key: string; + value: string; + path: string; + line: number; + column: number; + profile?: string | null; + overridesBaseValue: boolean; + targetPath?: string | null; + targetLine?: number | null; + targetColumn?: number | null; + }>; + propertyReferences: Array<{ + key: string; + path: string; + line: number; + column: number; + }>; + beans: Array<{ + id: string; + name: string; + typeName: string; + path: string; + line: number; + column: number; + kind: string; + }>; + injections: Array<{ + path: string; + line: number; + column: number; + typeName: string; + qualifier?: string | null; + beanIds: string[]; + }>; +} + +function coreData(response: CoreResponse): T { + if (response.ok) return response.data; + throw new Error(`${response.error.code}: ${response.error.message}`); +} + +export async function requestSpringIndex(args: { + root: string; + paths: string[]; + metadataRepositories?: string[]; + textOverrides?: Record; + refreshDependencyMetadata: boolean; +}): Promise { + const response = await executeCore({ + id: crypto.randomUUID(), + timeoutMilliseconds: args.refreshDependencyMetadata ? 60_000 : 30_000, + command: "spring.index", + payload: { + root: args.root, + paths: args.paths, + metadataRepositories: args.metadataRepositories ?? [], + textOverrides: args.textOverrides ?? {}, + refreshDependencyMetadata: args.refreshDependencyMetadata, + }, + }); + const data = coreData(response); + return { + properties: data.properties ?? [], + values: data.values ?? [], + propertyReferences: data.propertyReferences ?? [], + beans: data.beans ?? [], + injections: data.injections ?? [], + }; +} diff --git a/windows/tauri/src/features/spring/hooks/use-spring-index.ts b/windows/tauri/src/features/spring/hooks/use-spring-index.ts new file mode 100644 index 000000000..813846850 --- /dev/null +++ b/windows/tauri/src/features/spring/hooks/use-spring-index.ts @@ -0,0 +1,109 @@ +import { exists } from "@tauri-apps/plugin-fs"; +import { homeDir, join } from "@tauri-apps/api/path"; +import { useEffect, useRef } from "react"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { hasTextContent } from "@/features/panes/types/pane-content.types"; +import { requestSpringIndex } from "../api/spring-index-api"; +import { useSpringStore } from "../stores/spring.store"; +import { EMPTY_SPRING_INDEX } from "../types/spring.types"; +import { + collectSpringIndexPaths, + isSpringIndexPath, + workspaceRelativeSpringPath, +} from "../utils/spring-index-paths"; + +const RELOAD_DELAY_MS = 300; + +async function resolveMavenMetadataRepository(): Promise { + try { + const repository = await join(await homeDir(), ".m2", "repository"); + if (await exists(repository)) return repository; + } catch { + return undefined; + } + return undefined; +} + +export function useSpringIndex() { + const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); + const loadGeneration = useRef(0); + const reloadTimer = useRef | undefined>(undefined); + + useEffect(() => { + const store = useSpringStore.getState(); + if (!rootFolderPath) { + store.actions.reset(); + return; + } + + let cancelled = false; + + const load = async (refreshDependencyMetadata: boolean) => { + const generation = store.actions.beginLoad(rootFolderPath); + loadGeneration.current = generation; + try { + const files = await useFileSystemStore.getState().getAllProjectFiles(); + const paths = collectSpringIndexPaths( + files.map((file) => file.path), + rootFolderPath, + ); + const textOverrides: Record = {}; + for (const buffer of useBufferStore.getState().buffers) { + if (!buffer.path || !hasTextContent(buffer) || !isSpringIndexPath(buffer.path)) continue; + const relative = workspaceRelativeSpringPath(buffer.path, rootFolderPath); + if (relative) textOverrides[relative] = buffer.content; + } + const metadataRepository = refreshDependencyMetadata + ? await resolveMavenMetadataRepository() + : undefined; + const index = + paths.length === 0 + ? EMPTY_SPRING_INDEX + : await requestSpringIndex({ + root: rootFolderPath, + paths, + metadataRepositories: metadataRepository ? [metadataRepository] : [], + textOverrides, + refreshDependencyMetadata, + }); + if (cancelled) return; + useSpringStore.getState().actions.completeLoad(generation, rootFolderPath, index); + } catch (error) { + console.warn("Spring index failed:", error); + if (!cancelled) useSpringStore.getState().actions.failLoad(generation); + } + }; + + const scheduleReload = () => { + if (reloadTimer.current) clearTimeout(reloadTimer.current); + reloadTimer.current = setTimeout(() => { + void load(false); + }, RELOAD_DELAY_MS); + }; + + void load(true); + + const unsubscribeBuffers = useBufferStore.subscribe((state, previous) => { + const changed = state.buffers.some((buffer) => { + if (!buffer.path || !isSpringIndexPath(buffer.path) || !hasTextContent(buffer)) return false; + const previousBuffer = previous.buffers.find((candidate) => candidate.id === buffer.id); + return !previousBuffer || !hasTextContent(previousBuffer) || previousBuffer.content !== buffer.content; + }); + if (changed) scheduleReload(); + }); + + const handleExternalChange = (event: Event) => { + const path = (event as CustomEvent<{ path?: string }>).detail?.path; + if (path && isSpringIndexPath(path)) scheduleReload(); + }; + window.addEventListener("file-external-change", handleExternalChange); + + return () => { + cancelled = true; + unsubscribeBuffers(); + window.removeEventListener("file-external-change", handleExternalChange); + if (reloadTimer.current) clearTimeout(reloadTimer.current); + }; + }, [rootFolderPath]); +} diff --git a/windows/tauri/src/features/spring/stores/spring.store.ts b/windows/tauri/src/features/spring/stores/spring.store.ts new file mode 100644 index 000000000..95570b4de --- /dev/null +++ b/windows/tauri/src/features/spring/stores/spring.store.ts @@ -0,0 +1,57 @@ +import { createStore } from "zustand/vanilla"; +import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { EMPTY_SPRING_INDEX, type SpringIndex } from "../types/spring.types"; + +interface SpringState { + root: string | null; + index: SpringIndex; + isIndexing: boolean; + generation: number; + actions: { + beginLoad: (root: string) => number; + completeLoad: (generation: number, root: string, index: SpringIndex) => void; + failLoad: (generation: number) => void; + reset: () => void; + }; +} + +const createSpringStore = () => + createStore()((set, get) => ({ + root: null, + index: EMPTY_SPRING_INDEX, + isIndexing: false, + generation: 0, + actions: { + beginLoad: (root) => { + const generation = get().generation + 1; + set({ + root, + generation, + isIndexing: true, + }); + return generation; + }, + completeLoad: (generation, root, index) => { + if (get().generation !== generation) return; + set({ + root, + index, + isIndexing: false, + }); + }, + failLoad: (generation) => { + if (get().generation !== generation) return; + set({ isIndexing: false }); + }, + reset: () => { + set({ + root: null, + index: EMPTY_SPRING_INDEX, + isIndexing: false, + generation: get().generation + 1, + }); + }, + }, + })); + +export const useSpringStore = createWorkspaceScopedStore("spring", createSpringStore); diff --git a/windows/tauri/src/features/spring/types/spring.types.ts b/windows/tauri/src/features/spring/types/spring.types.ts new file mode 100644 index 000000000..f755ca48f --- /dev/null +++ b/windows/tauri/src/features/spring/types/spring.types.ts @@ -0,0 +1,71 @@ +export interface SpringProperty { + name: string; + typeName?: string | null; + description?: string | null; + defaultValue?: string | null; + sourcePath?: string | null; + sourceLine?: number | null; + sourceColumn?: number | null; +} + +export interface SpringConfigurationValue { + key: string; + value: string; + path: string; + line: number; + column: number; + profile?: string | null; + overridesBaseValue: boolean; + targetPath?: string | null; + targetLine?: number | null; + targetColumn?: number | null; +} + +export interface SpringPropertyReference { + key: string; + path: string; + line: number; + column: number; +} + +export interface SpringBean { + id: string; + name: string; + typeName: string; + path: string; + line: number; + column: number; + kind: string; +} + +export interface SpringInjection { + path: string; + line: number; + column: number; + typeName: string; + qualifier?: string | null; + beanIds: string[]; +} + +export interface SpringIndex { + properties: SpringProperty[]; + values: SpringConfigurationValue[]; + propertyReferences: SpringPropertyReference[]; + beans: SpringBean[]; + injections: SpringInjection[]; +} + +export interface SpringNavigationLocation { + filePath: string; + line: number; + column: number; + symbol: string; +} + +export const EMPTY_SPRING_INDEX: SpringIndex = { + properties: [], + values: [], + propertyReferences: [], + beans: [], + injections: [], +}; diff --git a/windows/tauri/src/features/spring/utils/spring-index-paths.ts b/windows/tauri/src/features/spring/utils/spring-index-paths.ts new file mode 100644 index 000000000..e693cb63c --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-index-paths.ts @@ -0,0 +1,31 @@ +import { getBaseName, getRelativePath, normalizePath } from "@/utils/path-helpers"; + +export function isSpringIndexPath(filePath: string): boolean { + const name = getBaseName(filePath).toLowerCase(); + if (name.endsWith(".java")) return true; + if (name === "spring-configuration-metadata.json") return true; + if (name === "additional-spring-configuration-metadata.json") return true; + return isSpringConfigurationPath(filePath); +} + +export function isSpringConfigurationPath(filePath: string): boolean { + const name = getBaseName(filePath).toLowerCase(); + if (name === "application.properties") return true; + if (name.startsWith("application-") && name.endsWith(".properties")) return true; + if (name === "application.yml" || name === "application.yaml") return true; + return name.startsWith("application-") && (name.endsWith(".yml") || name.endsWith(".yaml")); +} + +export function workspaceRelativeSpringPath(filePath: string, root: string): string { + return getRelativePath(filePath, root).replace(/\\/g, "/"); +} + +export function collectSpringIndexPaths(filePaths: readonly string[], root: string): string[] { + const paths = new Set(); + for (const filePath of filePaths) { + if (!isSpringIndexPath(filePath)) continue; + const relative = workspaceRelativeSpringPath(filePath, root); + if (relative) paths.add(normalizePath(relative)); + } + return [...paths].sort(); +} diff --git a/windows/tauri/src/features/spring/utils/spring-navigation.test.ts b/windows/tauri/src/features/spring/utils/spring-navigation.test.ts new file mode 100644 index 000000000..fb0b3bb7c --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-navigation.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import type { SpringIndex } from "../types/spring.types"; +import { collectSpringIndexPaths, isSpringConfigurationPath, isSpringIndexPath } from "./spring-index-paths"; +import { resolveSpringDefinitions, resolveSpringReferences } from "./spring-navigation"; + +const ROOT = "C:/work/demo"; + +const index: SpringIndex = { + properties: [ + { + name: "server.port", + sourcePath: "src/main/java/com/demo/ServerProperties.java", + sourceLine: 8, + sourceColumn: 5, + }, + ], + values: [ + { + key: "server.port", + value: "8080", + path: "src/main/resources/application.yml", + line: 2, + column: 3, + overridesBaseValue: false, + targetPath: "src/main/java/com/demo/ServerProperties.java", + targetLine: 8, + targetColumn: 5, + }, + ], + propertyReferences: [ + { + key: "server.port", + path: "src/main/java/com/demo/ApiController.java", + line: 14, + column: 12, + }, + ], + beans: [ + { + id: "userService", + name: "userService", + typeName: "com.demo.UserService", + path: "src/main/java/com/demo/UserService.java", + line: 6, + column: 1, + kind: "component", + }, + ], + injections: [ + { + path: "src/main/java/com/demo/ApiController.java", + line: 10, + column: 5, + typeName: "com.demo.UserService", + beanIds: ["userService"], + }, + ], +}; + +describe("Spring index path filters", () => { + test("keeps Java, application config, and metadata files", () => { + expect(isSpringIndexPath("C:/work/App.java")).toBe(true); + expect(isSpringConfigurationPath("C:/work/src/main/resources/application-dev.yml")).toBe(true); + expect(isSpringIndexPath("C:/work/README.md")).toBe(false); + expect( + collectSpringIndexPaths( + [ + "C:/work/demo/src/main/java/App.java", + "C:/work/demo/src/main/resources/application.yml", + "C:/work/demo/README.md", + ], + ROOT, + ), + ).toEqual(["src/main/java/App.java", "src/main/resources/application.yml"]); + }); +}); + +describe("Spring definition navigation", () => { + test("jumps from a configuration value to the Java declaration and @Value uses", () => { + const locations = resolveSpringDefinitions( + index, + ROOT, + "C:/work/demo/src/main/resources/application.yml", + 1, + ); + expect(locations.map((location) => `${location.filePath}:${location.line}`)).toEqual([ + "C:/work/demo/src/main/java/com/demo/ServerProperties.java:7", + "C:/work/demo/src/main/java/com/demo/ApiController.java:13", + ]); + }); + + test("jumps from a @Value reference back to the configuration document", () => { + const locations = resolveSpringDefinitions( + index, + ROOT, + "C:/work/demo/src/main/java/com/demo/ApiController.java", + 13, + ); + expect(locations).toEqual([ + { + filePath: "C:/work/demo/src/main/resources/application.yml", + line: 1, + column: 2, + symbol: "server.port", + }, + ]); + }); + + test("jumps from an injection point to the matching bean", () => { + const locations = resolveSpringDefinitions( + index, + ROOT, + "C:/work/demo/src/main/java/com/demo/ApiController.java", + 9, + ); + expect(locations).toEqual([ + { + filePath: "C:/work/demo/src/main/java/com/demo/UserService.java", + line: 5, + column: 0, + symbol: "userService", + }, + ]); + }); +}); + +describe("Spring reference navigation", () => { + test("collects the configuration value and every @Value use for the same key", () => { + const locations = resolveSpringReferences( + index, + ROOT, + "C:/work/demo/src/main/resources/application.yml", + 1, + ); + expect(locations.map((location) => location.filePath)).toContain( + "C:/work/demo/src/main/resources/application.yml", + ); + expect(locations.map((location) => location.filePath)).toContain( + "C:/work/demo/src/main/java/com/demo/ApiController.java", + ); + }); +}); diff --git a/windows/tauri/src/features/spring/utils/spring-navigation.ts b/windows/tauri/src/features/spring/utils/spring-navigation.ts new file mode 100644 index 000000000..5801189c8 --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-navigation.ts @@ -0,0 +1,183 @@ +import { joinPath, normalizePath } from "@/utils/path-helpers"; +import type { + SpringIndex, + SpringNavigationLocation, +} from "../types/spring.types"; +import { workspaceRelativeSpringPath } from "./spring-index-paths"; + +function matchesLine(indexLine: number, caretZeroBased: number, tolerance = 0): boolean { + return Math.abs(indexLine - (caretZeroBased + 1)) <= tolerance; +} + +function matchesPath(indexPath: string, relativePath: string): boolean { + return normalizePath(indexPath) === normalizePath(relativePath); +} + +function toEditorLocation( + root: string, + relativePath: string, + line?: number | null, + column?: number | null, + symbol = "", +): SpringNavigationLocation | null { + if (!relativePath) return null; + return { + filePath: normalizePath(joinPath(root, relativePath)), + line: Math.max(0, (line ?? 1) - 1), + column: Math.max(0, (column ?? 1) - 1), + symbol, + }; +} + +function uniqueLocations(locations: SpringNavigationLocation[]): SpringNavigationLocation[] { + const seen = new Set(); + const unique: SpringNavigationLocation[] = []; + for (const location of locations) { + const key = `${normalizePath(location.filePath)}:${location.line}:${location.column}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(location); + } + return unique; +} + +export function resolveSpringDefinitions( + index: SpringIndex, + root: string, + filePath: string, + caretLine: number, +): SpringNavigationLocation[] { + const relativePath = workspaceRelativeSpringPath(filePath, root); + if (!relativePath) return []; + + const value = index.values.find( + (candidate) => matchesPath(candidate.path, relativePath) && matchesLine(candidate.line, caretLine), + ); + if (value) { + const locations: SpringNavigationLocation[] = []; + if (value.targetPath) { + const target = toEditorLocation( + root, + value.targetPath, + value.targetLine, + value.targetColumn, + value.key, + ); + if (target) locations.push(target); + } + for (const reference of index.propertyReferences.filter((candidate) => candidate.key === value.key)) { + const location = toEditorLocation(root, reference.path, reference.line, reference.column, value.key); + if (location) locations.push(location); + } + if (locations.length > 0) return uniqueLocations(locations); + } + + const reference = index.propertyReferences.find( + (candidate) => matchesPath(candidate.path, relativePath) && matchesLine(candidate.line, caretLine), + ); + if (reference) { + return uniqueLocations( + index.values + .filter((candidate) => candidate.key === reference.key) + .flatMap((candidate) => { + const location = toEditorLocation( + root, + candidate.path, + candidate.line, + candidate.column, + candidate.key, + ); + return location ? [location] : []; + }), + ); + } + + const injection = index.injections.find( + (candidate) => + matchesPath(candidate.path, relativePath) && matchesLine(candidate.line, caretLine, 1), + ); + if (injection) { + return uniqueLocations( + injection.beanIds.flatMap((beanId) => { + const bean = index.beans.find((candidate) => candidate.id === beanId); + if (!bean) return []; + const location = toEditorLocation(root, bean.path, bean.line, bean.column, bean.name); + return location ? [location] : []; + }), + ); + } + + const matchingProperties = index.properties.filter( + (property) => + property.sourcePath && + matchesPath(property.sourcePath, relativePath) && + property.sourceLine != null && + matchesLine(property.sourceLine, caretLine, 1), + ); + return uniqueLocations( + matchingProperties.flatMap((property) => + index.values + .filter((candidate) => candidate.key === property.name) + .flatMap((candidate) => { + const location = toEditorLocation( + root, + candidate.path, + candidate.line, + candidate.column, + candidate.key, + ); + return location ? [location] : []; + }), + ), + ); +} + +export function resolveSpringReferences( + index: SpringIndex, + root: string, + filePath: string, + caretLine: number, +): SpringNavigationLocation[] { + const definitions = resolveSpringDefinitions(index, root, filePath, caretLine); + if (definitions.length === 0) return []; + + const relativePath = workspaceRelativeSpringPath(filePath, root); + const value = index.values.find( + (candidate) => matchesPath(candidate.path, relativePath) && matchesLine(candidate.line, caretLine), + ); + const reference = index.propertyReferences.find( + (candidate) => matchesPath(candidate.path, relativePath) && matchesLine(candidate.line, caretLine), + ); + const key = value?.key ?? reference?.key; + if (!key) return definitions; + + const origin = toEditorLocation(root, relativePath, caretLine + 1, 1, key); + return uniqueLocations([ + ...(origin ? [origin] : []), + ...definitions, + ...index.values + .filter((candidate) => candidate.key === key) + .flatMap((candidate) => { + const location = toEditorLocation( + root, + candidate.path, + candidate.line, + candidate.column, + candidate.key, + ); + return location ? [location] : []; + }), + ...index.propertyReferences + .filter((candidate) => candidate.key === key) + .flatMap((candidate) => { + const location = toEditorLocation( + root, + candidate.path, + candidate.line, + candidate.column, + candidate.key, + ); + return location ? [location] : []; + }), + ]); +} From d778e1f5ae9425af4b069df9b9d6810f58c8b26e Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 17 Aug 2026 10:29:47 +0800 Subject: [PATCH 2/3] feat(windows): explain a missing Java language server on jump Tell the user when jdtls is starting, failed, or not ready instead of reporting that no definition exists. --- .../lsp/language-server-navigation.test.ts | 39 +++++++++++++++++++ .../editor/lsp/language-server-navigation.ts | 25 ++++++++++++ .../src/features/editor/lsp/lsp-client.ts | 4 ++ 3 files changed, 68 insertions(+) create mode 100644 windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts create mode 100644 windows/tauri/src/features/editor/lsp/language-server-navigation.ts diff --git a/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts b/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts new file mode 100644 index 000000000..7102023c4 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { languageServerUnavailableMessage } from "./language-server-navigation"; + +describe("language server jump messages", () => { + test("does not warn when a session is already connected", () => { + expect( + languageServerUnavailableMessage({ + languageId: "java", + status: "connected", + hasSession: true, + }), + ).toBeNull(); + }); + + test("reports startup, failure, and not-ready states", () => { + expect( + languageServerUnavailableMessage({ + languageId: "java", + status: "connecting", + hasSession: false, + }), + ).toBe("Java language server is starting."); + expect( + languageServerUnavailableMessage({ + languageId: "java", + status: "error", + lastError: "Could not find jdtls. Install Eclipse JDT Language Server and add it to PATH.", + hasSession: false, + }), + ).toBe("Could not find jdtls. Install Eclipse JDT Language Server and add it to PATH."); + expect( + languageServerUnavailableMessage({ + languageId: "java", + status: "disconnected", + hasSession: false, + }), + ).toBe("Java language server is not ready."); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/language-server-navigation.ts b/windows/tauri/src/features/editor/lsp/language-server-navigation.ts new file mode 100644 index 000000000..bebf2a699 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/language-server-navigation.ts @@ -0,0 +1,25 @@ +import type { LspStatus } from "./stores/lsp.store"; + +export function languageDisplayName(languageId: string | undefined): string { + if (!languageId) return "Language"; + if (languageId === "java") return "Java"; + return languageId; +} + +export function languageServerUnavailableMessage(args: { + languageId?: string; + status: LspStatus; + lastError?: string; + hasSession: boolean; +}): string | null { + if (args.hasSession && args.status === "connected") return null; + + const name = languageDisplayName(args.languageId); + if (args.status === "connecting") { + return `${name} language server is starting.`; + } + if (args.status === "error") { + return args.lastError?.trim() || `${name} language server failed.`; + } + return args.lastError?.trim() || `${name} language server is not ready.`; +} diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index a87f3b1dd..4df22d423 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -658,6 +658,10 @@ export class LspClient { } } + hasSessionForFile(filePath: string): boolean { + return this.findServerKeyForFile(filePath, languageIdForEditorFile(filePath)) !== null; + } + /** * Get display name for a language ID */ From ff74e6bf1d5f4342cb72e41ee67685dd0217ebc0 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 17 Aug 2026 10:47:13 +0800 Subject: [PATCH 3/3] feat(windows): use IDEA-style definition and usage shortcuts Ctrl+click goes to the declaration. Ctrl+B finds usages, and a single remaining reference opens that location instead of the references pane. --- .../editor/components/monaco-editor.tsx | 17 ++++- .../editor-context-menu-items.tsx | 2 +- .../utils/go-to-definition-gesture.test.ts | 31 +++++++++ .../editor/utils/go-to-definition-gesture.ts | 14 ++++ .../keymaps/commands/command-registry.ts | 2 +- .../commands/navigation-command-actions.ts | 68 ++++++++++++++++--- .../defaults/default-keymaps.git-log.test.ts | 20 ++++++ .../keymaps/defaults/default-keymaps.ts | 13 +++- .../keymaps/defaults/keybinding-presets.ts | 3 +- .../window/components/window-menu-bar.tsx | 2 +- 10 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts create mode 100644 windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index 069fc97fc..bee2eca18 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -47,6 +47,7 @@ import { useEditorStateStore } from "../stores/state.store"; import type { EditorContentChangeOptions, Position, Range } from "../types/editor.types"; import { getBufferById } from "../utils/buffer-index"; import { fileOpenBenchmark } from "../utils/file-open-benchmark"; +import { isEditorGoToDefinitionModifierClick } from "../utils/go-to-definition-gesture"; import { getLanguageIdFromPath } from "../utils/language-id"; import { toggleCaseText } from "../utils/text-operations"; import { editorAPI } from "../extensions/api"; @@ -798,7 +799,21 @@ export function MonacoEditor({ syncCursorAndSelection(); }), editor.onMouseDown((event) => { - if (event.event.leftButton) mouseSelectingRef.current = true; + const mouseEvent = event.event; + if ( + isEditorGoToDefinitionModifierClick(mouseEvent) && + event.target.type === monacoEditor.MouseTargetType.CONTENT_TEXT && + event.target.position + ) { + mouseEvent.preventDefault(); + mouseEvent.stopPropagation(); + mouseSelectingRef.current = false; + editor.setPosition(event.target.position); + syncCursorAndSelection(); + void keymapRegistry.executeCommand("editor.goToDefinition"); + return; + } + if (mouseEvent.leftButton) mouseSelectingRef.current = true; }), editor.onMouseUp(() => { if (!mouseSelectingRef.current) return; diff --git a/windows/tauri/src/features/editor/context-menu/editor-context-menu-items.tsx b/windows/tauri/src/features/editor/context-menu/editor-context-menu-items.tsx index bb45e9a09..fc7a06bd7 100644 --- a/windows/tauri/src/features/editor/context-menu/editor-context-menu-items.tsx +++ b/windows/tauri/src/features/editor/context-menu/editor-context-menu-items.tsx @@ -241,7 +241,7 @@ export function buildEditorContextMenuItems({ id: "find-references", label: t("editor.findAllReferences"), icon: , - keybinding: , + keybinding: , disabled: isDisabled(onFindReferences), onClick: onFindReferences ?? noop, }, diff --git a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts new file mode 100644 index 000000000..f58cf8ff7 --- /dev/null +++ b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { isEditorGoToDefinitionModifierClick } from "./go-to-definition-gesture"; + +describe("IDEA-style go to definition click", () => { + test("accepts unmodified Ctrl or Cmd left clicks", () => { + expect( + isEditorGoToDefinitionModifierClick({ leftButton: true, ctrlKey: true }), + ).toBe(true); + expect( + isEditorGoToDefinitionModifierClick({ leftButton: true, metaKey: true }), + ).toBe(true); + }); + + test("ignores right clicks and extra modifiers", () => { + expect(isEditorGoToDefinitionModifierClick({ leftButton: false, ctrlKey: true })).toBe(false); + expect( + isEditorGoToDefinitionModifierClick({ + leftButton: true, + ctrlKey: true, + shiftKey: true, + }), + ).toBe(false); + expect( + isEditorGoToDefinitionModifierClick({ + leftButton: true, + ctrlKey: true, + altKey: true, + }), + ).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts new file mode 100644 index 000000000..8e32fa117 --- /dev/null +++ b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts @@ -0,0 +1,14 @@ +export function isEditorGoToDefinitionModifierClick(event: { + leftButton?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +}): boolean { + return Boolean( + event.leftButton && + (event.ctrlKey || event.metaKey) && + !event.altKey && + !event.shiftKey, + ); +} diff --git a/windows/tauri/src/features/keymaps/commands/command-registry.ts b/windows/tauri/src/features/keymaps/commands/command-registry.ts index 352194081..84cc32135 100644 --- a/windows/tauri/src/features/keymaps/commands/command-registry.ts +++ b/windows/tauri/src/features/keymaps/commands/command-registry.ts @@ -895,7 +895,7 @@ const navigationCommands: Command[] = [ id: "editor.goToReferences", title: "Go to References", category: "Navigation", - keybinding: "shift+F12", + keybinding: "cmd+b", execute: goToReferences, }, { diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts index 10256a3bb..b97ebdc5e 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -20,7 +20,7 @@ import { resolveSpringReferences, } from "@/features/spring/utils/spring-navigation"; import { useUIState } from "@/features/window/stores/ui-state.store"; -import { getBaseName } from "@/utils/path-helpers"; +import { getBaseName, normalizePath } from "@/utils/path-helpers"; import { showPromptDialog } from "@/ui/dialog"; import { toast } from "sonner"; @@ -58,6 +58,19 @@ function activeEditorNavigationContext() { return { bufferStore, activeBuffer, editorState }; } +function canonicalizeEditorPath(path: string): string { + return normalizePath(path).replace(/^\/([A-Za-z]:)/, "$1"); +} + +function isCurrentNavigationTarget( + filePath: string, + line: number, + targetPath: string, + targetLine: number, +): boolean { + return canonicalizeEditorPath(filePath) === canonicalizeEditorPath(targetPath) && line === targetLine; +} + function unavailableLanguageServerToast(filePath: string, lspClient: { hasSessionForFile(path: string): boolean }): string | null { const status = useLspStore.getState().lspStatus; return languageServerUnavailableMessage({ @@ -315,8 +328,20 @@ export async function goToReferences(): Promise { if (!activeBuffer?.path) return; - const springLocations = springLocationsForActiveFile("references"); - if (springLocations.length > 0) { + const springLocations = springLocationsForActiveFile("references").filter( + (location) => + !isCurrentNavigationTarget( + activeBuffer.path, + cursorPosition.line, + location.filePath, + location.line, + ), + ); + if (springLocations.length === 1) { + await navigateToSpringLocation(springLocations[0]); + return; + } + if (springLocations.length > 1) { await presentSpringReferences(springLocations, springLocations[0]?.symbol || "Spring"); return; } @@ -332,10 +357,6 @@ export async function goToReferences(): Promise { const wordEnd = currentLine.slice(cursorPosition.column).match(/^[\w$]*/); const symbol = (wordMatch?.[0] || "") + (wordEnd?.[0]?.slice(1) || ""); - const referencesActions = useReferencesStore.getState().actions; - referencesActions.setIsLoading(true); - bufferStore.actions.openReferencesBuffer(); - const references = await lspClient.getReferences( activeBuffer.path, cursorPosition.line, @@ -350,14 +371,41 @@ export async function goToReferences(): Promise { }; if (!references || references.length === 0) { - referencesActions.setReferences(origin, []); + toast.info("No references found."); return; } + const otherReferences = references.filter((reference) => { + const filePath = filePathFromUri(reference.uri); + return !isCurrentNavigationTarget( + activeBuffer.path, + cursorPosition.line, + filePath, + reference.range.start.line, + ); + }); + + if (otherReferences.length === 0) { + toast.info("No other references found."); + return; + } + + if (otherReferences.length === 1) { + const target = otherReferences[0]; + await goToActiveLspLocation("reference", async () => [target], { + requireLanguageServer: false, + }); + return; + } + + const referencesActions = useReferencesStore.getState().actions; + referencesActions.setIsLoading(true); + bufferStore.actions.openReferencesBuffer(); + const lineContextCache = new Map>(); const referenceLinesByFile = new Map>(); - for (const ref of references) { + for (const ref of otherReferences) { const filePath = filePathFromUri(ref.uri); const lineNumbers = referenceLinesByFile.get(filePath) ?? new Set(); lineNumbers.add(ref.range.start.line); @@ -387,7 +435,7 @@ export async function goToReferences(): Promise { lineContextCache.set(filePath, lines); } - const converted = references.map((ref) => { + const converted = otherReferences.map((ref) => { const filePath = filePathFromUri(ref.uri); const fileLines = lineContextCache.get(filePath); return { diff --git a/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts b/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts index c126fb92d..fd770c2db 100644 --- a/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts +++ b/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts @@ -2,6 +2,26 @@ import { describe, expect, test } from "bun:test"; import { SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS } from "@/features/layout/config/item-order"; import { defaultKeymaps } from "./default-keymaps"; +describe("IDEA-style definition shortcuts", () => { + test("binds Ctrl/Cmd+B to find references while the editor is focused", () => { + expect(defaultKeymaps).toContainEqual({ + key: "cmd+b", + command: "editor.goToReferences", + source: "default", + when: "editorFocus", + }); + }); + + test("keeps the activity sidebar toggle off the editor Ctrl/Cmd+B shortcut", () => { + expect(defaultKeymaps).toContainEqual({ + key: "cmd+b", + command: "workbench.toggleActivitySidebar", + source: "default", + when: "!editorFocus", + }); + }); +}); + describe("Git Log workbench entry points", () => { test("binds the IntelliJ-compatible Alt+9 shortcut", () => { expect(defaultKeymaps).toContainEqual({ diff --git a/windows/tauri/src/features/keymaps/defaults/default-keymaps.ts b/windows/tauri/src/features/keymaps/defaults/default-keymaps.ts index 5118863f7..01785e881 100644 --- a/windows/tauri/src/features/keymaps/defaults/default-keymaps.ts +++ b/windows/tauri/src/features/keymaps/defaults/default-keymaps.ts @@ -312,7 +312,12 @@ export const defaultKeymaps: Keybinding[] = [ }, // View Operations - { key: "cmd+b", command: "workbench.toggleActivitySidebar", source: "default" }, + { + key: "cmd+b", + command: "workbench.toggleActivitySidebar", + source: "default", + when: "!editorFocus", + }, { key: "cmd+e", command: "workbench.toggleSidebar", source: "default" }, { key: "cmd+j", command: "workbench.toggleTerminal", source: "default" }, { key: "shift+f10", command: "workbench.toggleRun", source: "default" }, @@ -425,6 +430,12 @@ export const defaultKeymaps: Keybinding[] = [ source: "default", when: "editorFocus", }, + { + key: "cmd+b", + command: "editor.goToReferences", + source: "default", + when: "editorFocus", + }, { key: "shift+F12", command: "editor.goToReferences", diff --git a/windows/tauri/src/features/keymaps/defaults/keybinding-presets.ts b/windows/tauri/src/features/keymaps/defaults/keybinding-presets.ts index c5500b2bc..c86076b6f 100644 --- a/windows/tauri/src/features/keymaps/defaults/keybinding-presets.ts +++ b/windows/tauri/src/features/keymaps/defaults/keybinding-presets.ts @@ -79,6 +79,7 @@ export const keybindingPresetDefinitions: Record handleCommand("editor.goToTypeDefinition")}> Go to Type Definition - handleCommand("editor.goToReferences")}> + handleCommand("editor.goToReferences")}> Go to References handleCommand("editor.renameSymbol")}>