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/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/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
*/
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 8326ca91f..b97ebdc5e 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, normalizePath } from "@/utils/path-helpers";
import { showPromptDialog } from "@/ui/dialog";
import { toast } from "sonner";
@@ -40,6 +50,132 @@ 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 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({
+ 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 +184,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 +200,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 +231,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 +288,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,15 +328,35 @@ export async function goToReferences(): Promise {
if (!activeBuffer?.path) return;
+ 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;
+ }
+
+ 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$]*/);
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,
@@ -197,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);
@@ -234,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;
+ 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] : [];
+ }),
+ ]);
+}
diff --git a/windows/tauri/src/features/window/components/window-menu-bar.tsx b/windows/tauri/src/features/window/components/window-menu-bar.tsx
index 90624090a..4de8cd05c 100644
--- a/windows/tauri/src/features/window/components/window-menu-bar.tsx
+++ b/windows/tauri/src/features/window/components/window-menu-bar.tsx
@@ -383,7 +383,7 @@ const WindowMenuBar = ({
handleCommand("editor.goToTypeDefinition")}>
Go to Type Definition
- handleCommand("editor.goToReferences")}>
+ handleCommand("editor.goToReferences")}>
Go to References
handleCommand("editor.renameSymbol")}>