From 7d816f65227131d0777d93d381c5e0c82138acc8 Mon Sep 17 00:00:00 2001 From: he_dai_xiang Date: Mon, 24 Aug 2026 17:14:35 +0800 Subject: [PATCH] fix(input): preserve text between external file references --- .../editor/SendFilePathToInputAction.java | 29 ++-- .../ui/toolwindow/ClaudeChatWindow.java | 39 +++++ .../ui/toolwindow/ClaudeSDKToolWindow.java | 9 + .../ui/toolwindow/CodeSnippetManager.java | 97 +++++++++++ .../PendingFileReferencesBuffer.java | 43 +++++ .../PendingFileReferencesBufferTest.java | 57 +++++++ .../components/ChatInputBox/ChatInputBox.tsx | 4 +- .../ChatInputBox/hooks/useFileTags.test.ts | 62 +++++++ .../ChatInputBox/hooks/useFileTags.ts | 20 ++- .../hooks/useGlobalCallbacks.test.ts | 142 +++++++++++++++- .../ChatInputBox/hooks/useGlobalCallbacks.ts | 110 +++++++----- .../hooks/usePasteAndDrop.test.ts | 147 ++++++++++++++++ .../ChatInputBox/hooks/usePasteAndDrop.ts | 66 ++++++-- .../ChatInputBox/hooks/useTextContent.test.ts | 21 +++ .../ChatInputBox/hooks/useTextContent.ts | 22 ++- .../ChatInputBox/hooks/useTooltip.ts | 30 +++- .../ChatInputBox/utils/fileReferences.test.ts | 67 ++++++++ .../ChatInputBox/utils/fileReferences.ts | 160 ++++++++++++++++++ webview/src/global.d.ts | 8 +- 19 files changed, 1046 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/github/claudecodegui/ui/toolwindow/PendingFileReferencesBuffer.java create mode 100644 src/test/java/com/github/claudecodegui/ui/toolwindow/PendingFileReferencesBufferTest.java create mode 100644 webview/src/components/ChatInputBox/hooks/usePasteAndDrop.test.ts create mode 100644 webview/src/components/ChatInputBox/hooks/useTextContent.test.ts create mode 100644 webview/src/components/ChatInputBox/utils/fileReferences.test.ts create mode 100644 webview/src/components/ChatInputBox/utils/fileReferences.ts diff --git a/src/main/java/com/github/claudecodegui/action/editor/SendFilePathToInputAction.java b/src/main/java/com/github/claudecodegui/action/editor/SendFilePathToInputAction.java index ee24c111..91d6c50e 100644 --- a/src/main/java/com/github/claudecodegui/action/editor/SendFilePathToInputAction.java +++ b/src/main/java/com/github/claudecodegui/action/editor/SendFilePathToInputAction.java @@ -16,6 +16,8 @@ import com.intellij.openapi.wm.ToolWindow; import com.intellij.openapi.wm.ToolWindowManager; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -71,18 +73,19 @@ public class SendFilePathToInputAction extends AnAction implements DumbAware { return; } - // Build file path string (supports multi-selection) - StringBuilder pathBuilder = new StringBuilder(); - for (int i = 0; i < files.length; i++) { - if (i > 0) { - pathBuilder.append(" "); + // Keep the selection structured so spaces in one path cannot be + // confused with the separator between two selected files. + List filePaths = new ArrayList<>(files.length); + for (VirtualFile file : files) { + if (file != null && file.isValid()) { + filePaths.add(file.getPath()); } - // Add @ prefix with absolute path - pathBuilder.append("@").append(files[i].getPath()); } - - String filePaths = pathBuilder.toString(); - LOG.info("Sending file paths to input: " + filePaths); + if (filePaths.isEmpty()) { + LOG.warn("No valid files selected"); + return; + } + LOG.info("Sending " + filePaths.size() + " file path(s) to input"); // Send to chat window sendToChatWindow(project, filePaths); @@ -116,7 +119,7 @@ public class SendFilePathToInputAction extends AnAction implements DumbAware { /** * Send file paths to the plugin's chat input box. */ - private void sendToChatWindow(@NotNull Project project, @NotNull String filePaths) { + private void sendToChatWindow(@NotNull Project project, @NotNull List filePaths) { try { // Get the plugin tool window ToolWindowManager toolWindowManager = ToolWindowManager.getInstance(project); @@ -132,7 +135,7 @@ public class SendFilePathToInputAction extends AnAction implements DumbAware { ApplicationManager.getApplication().invokeLater(() -> { try { if (project.isDisposed()) { return; } - ClaudeSDKToolWindow.addSelectionFromExternal(project, filePaths); + ClaudeSDKToolWindow.addFileReferencesFromExternal(project, filePaths); LOG.info("Window activated and sent file paths to project: " + project.getName()); } catch (Exception ex) { LOG.warn("Failed to send file paths after activation: " + ex.getMessage(), ex); @@ -142,7 +145,7 @@ public class SendFilePathToInputAction extends AnAction implements DumbAware { }, true); } else { // Window is already visible, send content directly - ClaudeSDKToolWindow.addSelectionFromExternal(project, filePaths); + ClaudeSDKToolWindow.addFileReferencesFromExternal(project, filePaths); // Ensure window gets focus toolWindow.activate(null, true); LOG.info("Chat window activated and sent file paths to project: " + project.getName()); diff --git a/src/main/java/com/github/claudecodegui/ui/toolwindow/ClaudeChatWindow.java b/src/main/java/com/github/claudecodegui/ui/toolwindow/ClaudeChatWindow.java index 4955567a..2bbc364f 100644 --- a/src/main/java/com/github/claudecodegui/ui/toolwindow/ClaudeChatWindow.java +++ b/src/main/java/com/github/claudecodegui/ui/toolwindow/ClaudeChatWindow.java @@ -59,6 +59,7 @@ import java.awt.event.HierarchyEvent; import java.awt.event.HierarchyListener; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -149,6 +150,8 @@ public class ClaudeChatWindow { private Window observedSurfaceWindow; private volatile boolean hasEverBeenFrontendReady = false; private final PendingCodeSnippetBuffer pendingCodeSnippetBuffer = new PendingCodeSnippetBuffer(); + private final PendingFileReferencesBuffer pendingFileReferencesBuffer = + new PendingFileReferencesBuffer(); private volatile boolean slashCommandsFetched = false; private final AtomicBoolean restoredHistoryLoadStarted = new AtomicBoolean(false); @@ -1569,6 +1572,20 @@ public class ClaudeChatWindow { } } + /** + * Add project-tree paths through the dedicated structured file-reference + * bridge, buffering the batch until the WebView is ready when necessary. + */ + public void addFileReferencesFromExternal(List filePaths) { + if (filePaths == null || filePaths.isEmpty()) { + return; + } + List toEmit = pendingFileReferencesBuffer.offer(filePaths, frontendReady); + if (toEmit != null) { + addFileReferences(toEmit); + } + } + private void flushPendingCodeSnippet() { String snippet = pendingCodeSnippetBuffer.takePending(); if (snippet != null) { @@ -1576,6 +1593,13 @@ public class ClaudeChatWindow { } } + private void flushPendingFileReferences() { + List filePaths = pendingFileReferencesBuffer.takePending(); + if (filePaths != null) { + addFileReferences(filePaths); + } + } + private void updateFrontendReadyState(boolean ready) { FrontendReadyTransition transition = frontendReadyTransitions.update(ready); frontendReady = ready; @@ -1586,6 +1610,7 @@ public class ClaudeChatWindow { } hasEverBeenFrontendReady = true; flushPendingCodeSnippet(); + flushPendingFileReferences(); ApplicationManager.getApplication().invokeLater(() -> { completeFrontendReadyUiUpdate( disposed, @@ -2718,6 +2743,20 @@ public class ClaudeChatWindow { } } + private void addFileReferences(List filePaths) { + if (filePaths == null || filePaths.isEmpty()) { + return; + } + + // Gson emits a JavaScript array literal, preserving each complete path + // (including spaces) as one typed callback argument. + String pathsJson = new Gson().toJson(filePaths); + if (browser != null) { + browser.getComponent().requestFocus(); + } + executeJavaScriptCode("window.insertFileReferencesAtCursor?.(" + pathsJson + ");"); + } + /** * Focus the chat input field in the frontend. * Called when Ctrl+Alt+K activates the panel without a selection. diff --git a/src/main/java/com/github/claudecodegui/ui/toolwindow/ClaudeSDKToolWindow.java b/src/main/java/com/github/claudecodegui/ui/toolwindow/ClaudeSDKToolWindow.java index 5b071281..90ec61bb 100644 --- a/src/main/java/com/github/claudecodegui/ui/toolwindow/ClaudeSDKToolWindow.java +++ b/src/main/java/com/github/claudecodegui/ui/toolwindow/ClaudeSDKToolWindow.java @@ -24,6 +24,7 @@ import javax.swing.*; import java.awt.*; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -555,6 +556,14 @@ public class ClaudeSDKToolWindow implements ToolWindowFactory, DumbAware { codeSnippetManager.addSelectionFromExternal(project, selectionInfo); } + /** + * Send project-tree file references to the selected chat tab as structured + * paths, keeping spaces inside a path separate from multi-file routing. + */ + public static void addFileReferencesFromExternal(Project project, List filePaths) { + codeSnippetManager.addFileReferencesFromExternal(project, filePaths); + } + /** * Register project closing listener to dispose all chat windows for the project. * This ensures proper cleanup when a project is closed. diff --git a/src/main/java/com/github/claudecodegui/ui/toolwindow/CodeSnippetManager.java b/src/main/java/com/github/claudecodegui/ui/toolwindow/CodeSnippetManager.java index 09ed3b09..dabc8bc0 100644 --- a/src/main/java/com/github/claudecodegui/ui/toolwindow/CodeSnippetManager.java +++ b/src/main/java/com/github/claudecodegui/ui/toolwindow/CodeSnippetManager.java @@ -9,6 +9,9 @@ import com.intellij.ui.content.Content; import com.intellij.ui.content.ContentManager; import com.intellij.util.concurrency.AppExecutorUtil; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Map; /** @@ -84,6 +87,69 @@ public class CodeSnippetManager { window.addCodeSnippetFromExternal(selectionInfo); } + /** + * Add structured file references from the project tree to the selected tab. + * This route never serializes the paths through the generic code-snippet + * bridge, so spaces in a path remain part of that one reference. + */ + public void addFileReferencesFromExternal(Project project, List filePaths) { + if (project == null) { + LOG.error("project is null"); + return; + } + + List snapshot = new ArrayList<>(); + if (filePaths != null) { + for (String filePath : filePaths) { + if (filePath != null && !filePath.trim().isEmpty()) { + snapshot.add(filePath); + } + } + } + if (snapshot.isEmpty()) { + return; + } + List immutablePaths = Collections.unmodifiableList(snapshot); + + ClaudeChatWindow window = getSelectedTabWindow(project); + if (window == null) { + window = instances.get(project); + } + + if (window == null) { + LOG.info("Window instance not found, opening tool window automatically: " + project.getName()); + ApplicationManager.getApplication().invokeLater(() -> { + try { + ToolWindow toolWindow = ToolWindowManager.getInstance(project).getToolWindow("CCG"); + if (toolWindow != null) { + toolWindow.show(null); + scheduleFileReferencesRetry(project, immutablePaths, 3); + } else { + LOG.error("Cannot find CCG tool window"); + } + } catch (Exception e) { + LOG.error("Error opening tool window: " + e.getMessage()); + } + }); + return; + } + + if (window.isDisposed()) { + if (window.getParentContent() != null) { + contentToWindowMap.remove(window.getParentContent()); + } + instances.remove(project); + return; + } + + if (!window.isInitialized()) { + scheduleFileReferencesRetry(project, immutablePaths, 3); + return; + } + + window.addFileReferencesFromExternal(immutablePaths); + } + /** * Get the ClaudeChatWindow for the currently selected tab. */ @@ -148,4 +214,35 @@ public class CodeSnippetManager { }); }, delay, java.util.concurrent.TimeUnit.MILLISECONDS); } + + /** Schedule structured file-reference delivery while the selected tab initializes. */ + private void scheduleFileReferencesRetry(Project project, List filePaths, int retriesLeft) { + if (retriesLeft <= 0) { + LOG.warn("Failed to add file references after max retries"); + return; + } + + int delay = 200 * (int) Math.pow(2, 3 - retriesLeft); + + AppExecutorUtil.getAppScheduledExecutorService().schedule(() -> { + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) { + return; + } + + ClaudeChatWindow retryWindow = getSelectedTabWindow(project); + if (retryWindow == null) { + retryWindow = instances.get(project); + } + + if (retryWindow != null && retryWindow.isInitialized() && !retryWindow.isDisposed()) { + retryWindow.addFileReferencesFromExternal(filePaths); + } else { + LOG.debug("Window not ready, retrying file references (retries left: " + + (retriesLeft - 1) + ")"); + scheduleFileReferencesRetry(project, filePaths, retriesLeft - 1); + } + }); + }, delay, java.util.concurrent.TimeUnit.MILLISECONDS); + } } diff --git a/src/main/java/com/github/claudecodegui/ui/toolwindow/PendingFileReferencesBuffer.java b/src/main/java/com/github/claudecodegui/ui/toolwindow/PendingFileReferencesBuffer.java new file mode 100644 index 00000000..a372533d --- /dev/null +++ b/src/main/java/com/github/claudecodegui/ui/toolwindow/PendingFileReferencesBuffer.java @@ -0,0 +1,43 @@ +package com.github.claudecodegui.ui.toolwindow; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Buffers structured file references until the webview frontend is ready. + * + *

The list is copied on both sides of the buffer so an external action + * cannot mutate the payload while a tab is waiting for initialization. + */ +final class PendingFileReferencesBuffer { + + private final AtomicReference> pending = new AtomicReference<>(); + + /** + * Return the paths immediately when the frontend is ready, otherwise defer + * the latest batch until the ready transition flushes it. + */ + List offer(List filePaths, boolean frontendReady) { + List snapshot = Collections.unmodifiableList(new ArrayList<>(filePaths)); + if (frontendReady) { + return snapshot; + } + pending.updateAndGet(existing -> { + if (existing == null) { + return snapshot; + } + List combined = new ArrayList<>(existing.size() + snapshot.size()); + combined.addAll(existing); + combined.addAll(snapshot); + return Collections.unmodifiableList(combined); + }); + return null; + } + + /** Atomically take the deferred batch, returning it only once. */ + List takePending() { + return pending.getAndSet(null); + } +} diff --git a/src/test/java/com/github/claudecodegui/ui/toolwindow/PendingFileReferencesBufferTest.java b/src/test/java/com/github/claudecodegui/ui/toolwindow/PendingFileReferencesBufferTest.java new file mode 100644 index 00000000..353601c2 --- /dev/null +++ b/src/test/java/com/github/claudecodegui/ui/toolwindow/PendingFileReferencesBufferTest.java @@ -0,0 +1,57 @@ +package com.github.claudecodegui.ui.toolwindow; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** Regression tests for structured file-reference buffering before WebView readiness. */ +public class PendingFileReferencesBufferTest { + + @Test + public void offerEmitsImmutableSnapshotWhenFrontendIsReady() { + PendingFileReferencesBuffer buffer = new PendingFileReferencesBuffer(); + List paths = new ArrayList<>(Arrays.asList( + "C:\\Program Files\\demo\\view file.xml", + "\\\\server\\share\\index.vue" + )); + + List emitted = buffer.offer(paths, true); + paths.set(0, "changed"); + + Assert.assertEquals("C:\\Program Files\\demo\\view file.xml", emitted.get(0)); + try { + emitted.add("another"); + Assert.fail("buffer payload should be immutable"); + } catch (UnsupportedOperationException expected) { + // Expected: delivery must not be mutable after it leaves the buffer. + } + Assert.assertNull(buffer.takePending()); + } + + @Test + public void deferredBatchIsFlushedOnlyOnce() { + PendingFileReferencesBuffer buffer = new PendingFileReferencesBuffer(); + List paths = Arrays.asList("/workspace/src/index.vue"); + + Assert.assertNull(buffer.offer(paths, false)); + Assert.assertEquals(paths, buffer.takePending()); + Assert.assertNull(buffer.takePending()); + } + + @Test + public void deferredBatchesAreCombinedInDeliveryOrder() { + PendingFileReferencesBuffer buffer = new PendingFileReferencesBuffer(); + + Assert.assertNull(buffer.offer(Arrays.asList("C:\\first.xml"), false)); + Assert.assertNull(buffer.offer(Arrays.asList("C:\\second.vue", "C:\\third.html"), false)); + + Assert.assertEquals( + Arrays.asList("C:\\first.xml", "C:\\second.vue", "C:\\third.html"), + buffer.takePending() + ); + Assert.assertNull(buffer.takePending()); + } +} diff --git a/webview/src/components/ChatInputBox/ChatInputBox.tsx b/webview/src/components/ChatInputBox/ChatInputBox.tsx index 090ab57a..64b4aa0e 100644 --- a/webview/src/components/ChatInputBox/ChatInputBox.tsx +++ b/webview/src/components/ChatInputBox/ChatInputBox.tsx @@ -207,7 +207,9 @@ export const ChatInputBox = memo(forwardRef { expect(editable.querySelectorAll('.file-tag').length).toBe(0); }); + it('does not promote an unregistered absolute path with following text into a tag', () => { + const editable = createEditable(); + const rawText = '@C:/project/templates/view.xml1414 @C:/project/pages/index.vue'; + editable.textContent = rawText; + + const { result } = setupHook(editable); + + result.current.renderFileTags(); + + expect(editable.querySelectorAll('.file-tag').length).toBe(0); + expect(editable.textContent).toBe(rawText); + }); + it('does not close completions or rewrite DOM for in-progress @query', () => { const editable = createEditable(); editable.textContent = '@b'; @@ -229,6 +242,55 @@ describe('useFileTags', () => { ]); }); + it('renders only a registered line-number reference with a spaced path', () => { + const editable = createEditable(); + editable.textContent = '@C:/Program Files/src/Main.java#L10-12 '; + mockSelection(); + + const { result } = setupHook(editable); + result.current.pathMappingRef.current.set( + 'C:/Program Files/src/Main.java', + 'C:/Program Files/src/Main.java' + ); + result.current.pathMappingRef.current.set( + 'C:/Program Files/src/Main.java#L10-12', + 'C:/Program Files/src/Main.java' + ); + + result.current.renderFileTags(); + + expect(editable.querySelector('.file-tag')?.getAttribute('data-file-path')).toBe( + 'C:/Program Files/src/Main.java#L10-12' + ); + }); + + it('keeps mapped markup references separate from text between files', () => { + const editable = createEditable(); + editable.textContent = + '@C:/project/templates/view.xml 1414 @C:/project/pages/index.vue '; + mockSelection(); + + const { result } = setupHook(editable); + result.current.pathMappingRef.current.set( + 'C:/project/templates/view.xml', + 'C:/project/templates/view.xml' + ); + result.current.pathMappingRef.current.set( + 'C:/project/pages/index.vue', + 'C:/project/pages/index.vue' + ); + + result.current.renderFileTags(); + + expect(Array.from(editable.querySelectorAll('.file-tag')).map((tag) => + tag.getAttribute('data-file-path') + )).toEqual([ + 'C:/project/templates/view.xml', + 'C:/project/pages/index.vue', + ]); + expect(editable.textContent).toContain('1414'); + }); + it('handles path at end of text without trailing space', () => { const editable = createEditable(); editable.textContent = '@src/a.ts'; diff --git a/webview/src/components/ChatInputBox/hooks/useFileTags.ts b/webview/src/components/ChatInputBox/hooks/useFileTags.ts index 8f55a500..440224ec 100644 --- a/webview/src/components/ChatInputBox/hooks/useFileTags.ts +++ b/webview/src/components/ChatInputBox/hooks/useFileTags.ts @@ -220,14 +220,22 @@ export function useFileTags({ const hashIndex = filePath.indexOf('#'); const pureFilePath = hashIndex !== -1 ? filePath.substring(0, hashIndex) : filePath; const pureFileName = pureFilePath.split(/[/\\]/).pop() || pureFilePath; + const isRegistered = pathMappingRef.current.has(pureFilePath) + || pathMappingRef.current.has(pureFileName) + || pathMappingRef.current.has(filePath); + if (isRegistered) return true; + + // Preserve the existing single-reference fallback for a manually typed + // absolute path or line reference. With multiple @ markers, boundaries + // are ambiguous unless the insertion route registered the exact paths; + // accepting an absolute-looking fallback there can absorb ordinary text + // between two references (issue #1726). + const hasSingleAtMarker = currentText.indexOf('@') === currentText.lastIndexOf('@'); + if (!hasSingleAtMarker) return false; + const hasLineNumber = /#L\d+/.test(filePath); const isAbsolutePath = /^[a-zA-Z]:[/\\]/.test(filePath) || filePath.startsWith('/'); - - return pathMappingRef.current.has(pureFilePath) - || pathMappingRef.current.has(pureFileName) - || pathMappingRef.current.has(filePath) - || hasLineNumber - || isAbsolutePath; + return hasLineNumber || isAbsolutePath; }; const matches = findMatches(currentText); diff --git a/webview/src/components/ChatInputBox/hooks/useGlobalCallbacks.test.ts b/webview/src/components/ChatInputBox/hooks/useGlobalCallbacks.test.ts index 24d2df50..af9cb33b 100644 --- a/webview/src/components/ChatInputBox/hooks/useGlobalCallbacks.test.ts +++ b/webview/src/components/ChatInputBox/hooks/useGlobalCallbacks.test.ts @@ -1,5 +1,7 @@ -import { renderHook } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { useGlobalCallbacks } from './useGlobalCallbacks.js'; +import { useFileTags } from './useFileTags.js'; +import { useTextContent } from './useTextContent.js'; function createEditable(): HTMLDivElement { const el = document.createElement('div'); @@ -48,6 +50,15 @@ function placeCaretInsideFirstTextNode(element: HTMLDivElement, offset: number): selection?.addRange(range); } +function placeCaretAtEnd(element: HTMLDivElement): void { + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + function renderUseGlobalCallbacks(editable: HTMLDivElement) { const pathMappingRef = { current: new Map() }; const setHasContent = vi.fn(); @@ -76,6 +87,7 @@ function renderUseGlobalCallbacks(editable: HTMLDivElement) { return { getTextContent, + pathMappingRef, setHasContent, adjustHeight, renderFileTags, @@ -83,6 +95,44 @@ function renderUseGlobalCallbacks(editable: HTMLDivElement) { }; } +function renderFileReferenceHarness(editable: HTMLDivElement) { + const editableRef = { current: editable }; + const setHasContent = vi.fn(); + const adjustHeight = vi.fn(); + const renderQuoteTags = vi.fn(); + const onInput = vi.fn(); + const closeAllCompletions = vi.fn(); + const focusInput = vi.fn(() => editable.focus()); + + return renderHook(() => { + const { getTextContent } = useTextContent({ editableRef }); + const fileTags = useFileTags({ + editableRef, + getTextContent, + onCloseCompletions: closeAllCompletions, + }); + + useGlobalCallbacks({ + editableRef, + pathMappingRef: fileTags.pathMappingRef, + getTextContent, + adjustHeight, + renderFileTags: fileTags.renderFileTags, + renderQuoteTags, + setHasContent, + onInput, + closeAllCompletions, + focusInput, + }); + + return { + getTextContent, + extractFileTags: fileTags.extractFileTags, + renderFileTags: fileTags.renderFileTags, + }; + }); +} + describe('useGlobalCallbacks', () => { beforeEach(() => { vi.useFakeTimers(); @@ -92,6 +142,7 @@ describe('useGlobalCallbacks', () => { vi.runOnlyPendingTimers(); vi.useRealTimers(); delete window.insertCodeSnippetAtCursor; + delete window.insertFileReferencesAtCursor; delete window.handleFilePathFromJava; document.body.innerHTML = ''; }); @@ -184,4 +235,93 @@ describe('useGlobalCallbacks', () => { expect(getTextContent()).toContain('existing draft'); expect(getTextContent()).toContain('@/abs/path/Helper.ts '); }); + + it('registers multiple dedicated absolute file references before rendering', () => { + const editable = createEditable(); + const { getTextContent, pathMappingRef } = renderUseGlobalCallbacks(editable); + + window.insertFileReferencesAtCursor?.([ + 'C:\\project\\templates\\view file.xml', + 'C:\\project\\pages\\index.vue', + ]); + vi.runAllTimers(); + + expect(getTextContent()).toBe( + '@C:\\project\\templates\\view file.xml @C:\\project\\pages\\index.vue ' + ); + expect(pathMappingRef.current.get('view file.xml')).toBe( + 'C:\\project\\templates\\view file.xml' + ); + expect(pathMappingRef.current.get('index.vue')).toBe( + 'C:\\project\\pages\\index.vue' + ); + }); + + it('keeps ordinary text separate across repeated external file insertions and reparsing', () => { + const editable = createEditable(); + const { result } = renderFileReferenceHarness(editable); + const firstPath = 'C:\\project\\templates\\view.xml'; + const secondPath = 'C:\\project\\pages\\index.vue'; + + act(() => { + window.insertFileReferencesAtCursor?.([firstPath]); + vi.runAllTimers(); + }); + + act(() => { + editable.appendChild(document.createTextNode('1234')); + placeCaretAtEnd(editable); + window.insertFileReferencesAtCursor?.([secondPath]); + vi.runAllTimers(); + }); + + expect(result.current.getTextContent()).toBe( + `@${firstPath} 1234@${secondPath} ` + ); + expect(result.current.extractFileTags()).toEqual([ + { displayPath: firstPath, absolutePath: firstPath }, + { displayPath: secondPath, absolutePath: secondPath }, + ]); + expect(editable.querySelectorAll('.file-tag')).toHaveLength(2); + + act(() => { + editable.appendChild(document.createTextNode('@')); + placeCaretAtEnd(editable); + result.current.renderFileTags(); + }); + + expect(result.current.getTextContent()).toBe( + `@${firstPath} 1234@${secondPath} @` + ); + expect(editable.querySelectorAll('.file-tag')).toHaveLength(2); + expect(editable.textContent).toContain('1234'); + }); + + it('keeps a generic code snippet separate from file-list parsing', () => { + const editable = createEditable(); + const { pathMappingRef } = renderUseGlobalCallbacks(editable); + + window.insertCodeSnippetAtCursor?.( + '@C:\\project\\templates\\view file.xml @C:\\project\\pages\\index.vue' + ); + vi.runAllTimers(); + + expect(pathMappingRef.current.size).toBe(0); + }); + + it('registers a strict line-number reference with spaces through the generic bridge', () => { + const editable = createEditable(); + const { getTextContent, pathMappingRef } = renderUseGlobalCallbacks(editable); + + window.insertCodeSnippetAtCursor?.('@C:\\Program Files\\src\\Main.java#L10-12'); + vi.runAllTimers(); + + expect(getTextContent()).toBe('@C:\\Program Files\\src\\Main.java#L10-12 '); + expect(pathMappingRef.current.get('C:\\Program Files\\src\\Main.java')).toBe( + 'C:\\Program Files\\src\\Main.java' + ); + expect(pathMappingRef.current.get('C:\\Program Files\\src\\Main.java#L10-12')).toBe( + 'C:\\Program Files\\src\\Main.java' + ); + }); }); diff --git a/webview/src/components/ChatInputBox/hooks/useGlobalCallbacks.ts b/webview/src/components/ChatInputBox/hooks/useGlobalCallbacks.ts index 62449a27..37895078 100644 --- a/webview/src/components/ChatInputBox/hooks/useGlobalCallbacks.ts +++ b/webview/src/components/ChatInputBox/hooks/useGlobalCallbacks.ts @@ -1,6 +1,10 @@ import { useEffect } from 'react'; import { createTextFragment } from '../utils/selectionUtils.js'; import { makeQuoteToken, registerQuote } from '../utils/quoteRegistry.js'; +import { + registerAbsoluteFileReference, + registerLineFileReference, +} from '../utils/fileReferences.js'; interface UseGlobalCallbacksOptions { editableRef: React.RefObject; @@ -39,21 +43,14 @@ export function useGlobalCallbacks({ /** * Insert a single file path into the input box */ - const insertSingleFilePath = (filePath: string) => { - if (!editableRef.current) return; + const insertSingleFilePath = (filePath: string): boolean => { + if (!editableRef.current) return false; - const absolutePath = filePath.trim(); - if (!absolutePath) return; + const absolutePath = registerAbsoluteFileReference(pathMappingRef.current, filePath); + if (!absolutePath) return false; - // Add path to path mapping - const fileName = absolutePath.split(/[/\\]/).pop() || absolutePath; - - // Add path to pathMappingRef to make it a "valid reference" - pathMappingRef.current.set(fileName, absolutePath); - pathMappingRef.current.set(absolutePath, absolutePath); - - // Insert file path into input box (auto-add @ prefix), add space to trigger rendering - const pathToInsert = (filePath.startsWith('@') ? filePath : `@${filePath}`) + ' '; + // File identity comes from exact registration, not inferred separators. + const pathToInsert = `@${absolutePath} `; const selection = window.getSelection(); if ( @@ -99,38 +96,48 @@ export function useGlobalCallbacks({ selection?.removeAllRanges(); selection?.addRange(range); } + return true; }; - window.handleFilePathFromJava = (filePathInput: string | string[]) => { + const normalizeFilePathInput = ( + filePathInput: string | string[], + allowJsonArrayString: boolean, + ): string[] => { + if (Array.isArray(filePathInput)) { + return filePathInput.filter((filePath): filePath is string => typeof filePath === 'string'); + } + if (typeof filePathInput !== 'string') return []; + + if (allowJsonArrayString) { + try { + const parsed: unknown = JSON.parse(filePathInput); + if (Array.isArray(parsed)) { + return parsed.filter((filePath): filePath is string => typeof filePath === 'string'); + } + } catch { + // Treat the legacy string as one path below. + } + } + return [filePathInput]; + }; + + const insertFileReferences = ( + filePathInput: string | string[], + allowJsonArrayString = false, + ) => { try { if (!editableRef.current) return; - // Normalize input to string array. - // Java side (v0.1.9+) passes a JS array directly via executeJavaScript, - // so Array.isArray branch is the primary path. - // The string branch is kept for backward compatibility with older Java - // versions that passed a single string. It can be removed once v0.1.8 - // support is no longer needed. - let filePaths: string[]; - if (Array.isArray(filePathInput)) { - filePaths = filePathInput; - } else if (typeof filePathInput === 'string') { - try { - const parsed: unknown = JSON.parse(filePathInput); - filePaths = Array.isArray(parsed) ? parsed : [filePathInput]; - } catch { - filePaths = [filePathInput]; - } - } else { - return; - } - - // Insert all file paths + const filePaths = normalizeFilePathInput(filePathInput, allowJsonArrayString); + let insertedCount = 0; for (const filePath of filePaths) { - if (filePath && filePath.trim()) { - insertSingleFilePath(filePath.trim()); + if (insertSingleFilePath(filePath)) { + insertedCount++; } } + if (insertedCount === 0) { + return; + } // Close all completion menus closeAllCompletions(); @@ -146,10 +153,22 @@ export function useGlobalCallbacks({ renderFileTags(); }); } catch (error) { - console.error('[useGlobalCallbacks] handleFilePathFromJava failed:', error); + console.error('[useGlobalCallbacks] insertFileReferencesAtCursor failed:', error); } }; + // Dedicated structured bridge used by the project-tree action. The Java + // side passes an actual array literal, while the string form supports one + // legacy path without guessing where spaces should split. + window.insertFileReferencesAtCursor = (filePathInput: string | string[]) => { + insertFileReferences(filePathInput); + }; + + // Keep the older callback for compatibility with existing integrations. + window.handleFilePathFromJava = (filePathInput: string | string[]) => { + insertFileReferences(filePathInput, true); + }; + // Initial focus — but only if no other input/editable element is focused (B-013) const active = document.activeElement; const isOtherInputFocused = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || @@ -160,6 +179,7 @@ export function useGlobalCallbacks({ // Cleanup function return () => { + delete window.insertFileReferencesAtCursor; delete window.handleFilePathFromJava; }; }, [ @@ -244,15 +264,23 @@ export function useGlobalCallbacks({ try { if (!editableRef.current) return; + // The generic bridge remains for selected code. Only the strict + // single line-number form is registered as a file reference; ordinary + // code and arbitrary @ text are inserted byte-for-byte as snippets. + const lineReference = registerLineFileReference(pathMappingRef.current, selectionInfo); + const normalizedSelectionInfo = lineReference + ? `@${lineReference}` + : selectionInfo; + // Read caret BEFORE focus() to avoid focus side-effects on selection. // If caret is inside the editable, insert at caret. Otherwise (e.g. window // just regained focus from an external IDE action with no prior caret), // fall back to appending at the end with a leading newline separator. - const insertedAtCaret = tryInsertExternalSnippetAtCaret(selectionInfo); + const insertedAtCaret = tryInsertExternalSnippetAtCaret(normalizedSelectionInfo); if (!insertedAtCaret) { editableRef.current.focus(); - appendExternalSnippetToEnd(selectionInfo); + appendExternalSnippetToEnd(normalizedSelectionInfo); } // Trigger state update @@ -356,5 +384,5 @@ export function useGlobalCallbacks({ delete window.focusChatInput; delete window.addQuotedSnippet; }; - }, [editableRef, getTextContent, renderFileTags, renderQuoteTags, adjustHeight, onInput, setHasContent, focusInput]); + }, [editableRef, pathMappingRef, getTextContent, renderFileTags, renderQuoteTags, adjustHeight, onInput, setHasContent, focusInput]); } diff --git a/webview/src/components/ChatInputBox/hooks/usePasteAndDrop.test.ts b/webview/src/components/ChatInputBox/hooks/usePasteAndDrop.test.ts new file mode 100644 index 00000000..4f6da2f4 --- /dev/null +++ b/webview/src/components/ChatInputBox/hooks/usePasteAndDrop.test.ts @@ -0,0 +1,147 @@ +import { act, renderHook } from '@testing-library/react'; +import type { Attachment } from '../types.js'; +import { usePasteAndDrop } from './usePasteAndDrop.js'; + +function createEditable(): HTMLDivElement { + const editable = document.createElement('div'); + editable.setAttribute('contenteditable', 'true'); + document.body.appendChild(editable); + return editable; +} + +function placeCaretAtEnd(editable: HTMLDivElement): void { + const range = document.createRange(); + range.selectNodeContents(editable); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +function createPasteEvent(text: string): React.ClipboardEvent { + return { + clipboardData: { + items: [{ kind: 'string', type: 'text/plain' }], + getData: (type: string) => type === 'text/plain' ? text : '', + }, + preventDefault: vi.fn(), + } as unknown as React.ClipboardEvent; +} + +function setupPasteHook(editable: HTMLDivElement) { + const pathMappingRef = { current: new Map() }; + const renderFileTags = vi.fn(); + const hook = renderHook(() => usePasteAndDrop({ + editableRef: { current: editable }, + pathMappingRef, + getTextContent: () => editable.textContent ?? '', + adjustHeight: vi.fn(), + renderFileTags, + setHasContent: vi.fn(), + setInternalAttachments: vi.fn() as unknown as React.Dispatch>, + onInput: vi.fn(), + closeAllCompletions: vi.fn(), + handleInput: vi.fn(), + flushInput: vi.fn(), + })); + + return { ...hook, pathMappingRef, renderFileTags }; +} + +describe('usePasteAndDrop file references', () => { + beforeEach(() => { + // happy-dom does not implement the deprecated command used by the + // production helper; returning false exercises its Range fallback. + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn(() => false), + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + Reflect.deleteProperty(document, 'execCommand'); + delete window.getClipboardFilePath; + }); + + it('registers and normalizes multiple explicit paths with spaces', () => { + const editable = createEditable(); + placeCaretAtEnd(editable); + const { result, pathMappingRef } = setupPasteHook(editable); + + result.current.handlePaste(createPasteEvent( + '@C:\\Program Files\\demo\\view file.xml @/workspace/src/index.vue' + )); + + expect(editable.textContent).toBe( + '@C:\\Program Files\\demo\\view file.xml @/workspace/src/index.vue ' + ); + expect(pathMappingRef.current.get('view file.xml')) + .toBe('C:\\Program Files\\demo\\view file.xml'); + expect(pathMappingRef.current.get('/workspace/src/index.vue')) + .toBe('/workspace/src/index.vue'); + }); + + it('keeps mixed ordinary text unchanged and does not register its @ text', () => { + const editable = createEditable(); + placeCaretAtEnd(editable); + const { result, pathMappingRef } = setupPasteHook(editable); + const mixedText = 'const email = "user@example.com"; @/workspace/src/index.vue'; + + result.current.handlePaste(createPasteEvent(mixedText)); + + expect(editable.textContent).toBe(mixedText); + expect(pathMappingRef.current.size).toBe(0); + }); + + it('keeps trailing prose after an absolute-looking reference unchanged', () => { + const editable = createEditable(); + placeCaretAtEnd(editable); + const { result, pathMappingRef } = setupPasteHook(editable); + const mixedText = '@C:\\workspace\\view.xml please review'; + + result.current.handlePaste(createPasteEvent(mixedText)); + + expect(editable.textContent).toBe(mixedText); + expect(pathMappingRef.current.size).toBe(0); + }); + + it('registers a pasted line reference with a spaced path', () => { + const editable = createEditable(); + placeCaretAtEnd(editable); + const { result, pathMappingRef } = setupPasteHook(editable); + + result.current.handlePaste(createPasteEvent( + '@C:\\Program Files\\src\\Main.java#L10-12' + )); + + expect(editable.textContent).toBe('@C:\\Program Files\\src\\Main.java#L10-12 '); + expect(pathMappingRef.current.get('C:\\Program Files\\src\\Main.java#L10-12')) + .toBe('C:\\Program Files\\src\\Main.java'); + }); + + it('registers a real clipboard file returned by the Java bridge', async () => { + const editable = createEditable(); + placeCaretAtEnd(editable); + const { result, pathMappingRef } = setupPasteHook(editable); + window.getClipboardFilePath = vi.fn().mockResolvedValue( + 'C:\\Program Files\\demo\\view file.xml' + ); + const event = { + clipboardData: { + items: [{ kind: 'file', type: 'application/xml' }], + getData: () => '', + }, + preventDefault: vi.fn(), + } as unknown as React.ClipboardEvent; + + await act(async () => { + result.current.handlePaste(event); + await Promise.resolve(); + }); + + expect(editable.textContent).toBe('@C:\\Program Files\\demo\\view file.xml '); + expect(pathMappingRef.current.get('view file.xml')) + .toBe('C:\\Program Files\\demo\\view file.xml'); + }); +}); diff --git a/webview/src/components/ChatInputBox/hooks/usePasteAndDrop.ts b/webview/src/components/ChatInputBox/hooks/usePasteAndDrop.ts index 984b6e7b..d0c47e1d 100644 --- a/webview/src/components/ChatInputBox/hooks/usePasteAndDrop.ts +++ b/webview/src/components/ChatInputBox/hooks/usePasteAndDrop.ts @@ -2,6 +2,11 @@ import { useCallback, useEffect } from 'react'; import type { Attachment } from '../types.js'; import { generateId } from '../utils/generateId.js'; import { insertTextAtCursor } from '../utils/selectionUtils.js'; +import { + parseExplicitFileReferences, + registerAbsoluteFileReference, + registerLineFileReference, +} from '../utils/fileReferences.js'; import { perfTimer } from '../../../utils/debug.js'; declare global { @@ -137,12 +142,23 @@ export function usePasteAndDrop({ .getClipboardFilePath() .then((fullPath: string) => { if (fullPath && fullPath.trim()) { - // Insert full path using modern Selection API - insertTextAtCursor(fullPath, editableRef.current); + const registeredPath = registerAbsoluteFileReference( + pathMappingRef.current, + fullPath, + ); + // A real clipboard file is structured input from Java, so it + // can safely become a file reference without text guessing. + insertTextAtCursor( + registeredPath ? `@${registeredPath} ` : fullPath, + editableRef.current, + ); // Bypass IME guard (isComposingRef may be stale after recent compositionEnd) handleInput(); // Immediately sync parent state without waiting for debounce flushInput(); + if (registeredPath) { + requestAnimationFrame(() => renderFileTags()); + } } }) .catch(() => { @@ -156,8 +172,24 @@ export function usePasteAndDrop({ const timer = perfTimer('handlePaste-text'); timer.mark(`text-length:${text.length}`); + // Only an entire, explicitly marked @ absolute-path payload is + // promoted to file references. Mixed prose/code/email/annotation + // text remains ordinary pasted text and never enters the mapping. + const lineReference = registerLineFileReference(pathMappingRef.current, text); + const explicitPaths = lineReference ? null : parseExplicitFileReferences(text); + const registeredPaths = explicitPaths?.map((filePath) => + registerAbsoluteFileReference(pathMappingRef.current, filePath) + ); + const normalizedFileReferenceText = lineReference + ? `@${lineReference} ` + : registeredPaths && + registeredPaths.every((filePath): filePath is string => filePath !== null) + ? `${registeredPaths.map((filePath) => `@${filePath}`).join(' ')} ` + : null; + const textToInsert = normalizedFileReferenceText ?? text; + // Use modern Selection API to insert plain text (maintains cursor position) - insertTextAtCursor(text, editableRef.current); + insertTextAtCursor(textToInsert, editableRef.current); timer.mark('insertText'); // Trigger input event to update state @@ -171,6 +203,9 @@ export function usePasteAndDrop({ // Scroll to make cursor visible after paste // Use requestAnimationFrame to ensure DOM updates are complete requestAnimationFrame(() => { + if (normalizedFileReferenceText) { + renderFileTags(); + } // Get the wrapper element that has overflow scroll const wrapper = editableRef.current?.parentElement; if (wrapper && editableRef.current) { @@ -183,7 +218,14 @@ export function usePasteAndDrop({ } } }, - [setInternalAttachments, handleInput, flushInput] + [ + editableRef, + pathMappingRef, + renderFileTags, + setInternalAttachments, + handleInput, + flushInput, + ] ); /** @@ -250,16 +292,12 @@ export function usePasteAndDrop({ // No image files, process text (file path or other text) if (text && text.trim()) { - // Extract file path and add to path mapping - const filePath = text.trim(); - const fileName = filePath.split(/[/\\]/).pop() || filePath; - - // Add path to pathMappingRef to make it a "valid reference" - pathMappingRef.current.set(fileName, filePath); - pathMappingRef.current.set(filePath, filePath); - - // Auto-add @ prefix (if not already present), and add space to trigger rendering - const textToInsert = (text.startsWith('@') ? text : `@${text}`) + ' '; + // Reuse the same absolute-path registration used by paste and the + // dedicated Java bridge. Relative/non-path drops remain plain text. + const filePath = registerAbsoluteFileReference(pathMappingRef.current, text); + const textToInsert = filePath + ? `@${filePath} ` + : `${text.startsWith('@') ? text : `@${text}`} `; // Get current cursor position const selection = window.getSelection(); diff --git a/webview/src/components/ChatInputBox/hooks/useTextContent.test.ts b/webview/src/components/ChatInputBox/hooks/useTextContent.test.ts new file mode 100644 index 00000000..578cf38c --- /dev/null +++ b/webview/src/components/ChatInputBox/hooks/useTextContent.test.ts @@ -0,0 +1,21 @@ +import { renderHook } from '@testing-library/react'; +import { useTextContent } from './useTextContent.js'; + +describe('useTextContent', () => { + it('does not reuse cached text when different HTML has the same length', () => { + const editable = document.createElement('div'); + document.body.appendChild(editable); + const editableRef = { current: editable }; + const { result } = renderHook(() => useTextContent({ editableRef })); + + editable.innerHTML = 'alpha'; + expect(result.current.getTextContent()).toBe('alpha'); + + // The two HTML strings have the same length. A length-only cache returns + // the stale first value here, which made icon-dependent tag rendering flaky. + editable.innerHTML = 'bravo'; + expect(result.current.getTextContent()).toBe('bravo'); + + document.body.removeChild(editable); + }); +}); diff --git a/webview/src/components/ChatInputBox/hooks/useTextContent.ts b/webview/src/components/ChatInputBox/hooks/useTextContent.ts index e3e94a30..f48561f4 100644 --- a/webview/src/components/ChatInputBox/hooks/useTextContent.ts +++ b/webview/src/components/ChatInputBox/hooks/useTextContent.ts @@ -4,7 +4,8 @@ import { makeQuoteToken } from '../utils/quoteRegistry.js'; interface TextContentCache { content: string; - htmlLength: number; + htmlSnapshot: string; + valid: boolean; timestamp: number; } @@ -24,7 +25,7 @@ interface UseTextContentReturn { * * Performance optimization: * - Uses cache to avoid repeated DOM traversal - * - Cache is invalidated when innerHTML length changes + * - Cache is invalidated when the exact innerHTML snapshot changes * - Properly handles file tags by reading data-file-path attribute */ export function useTextContent({ @@ -32,7 +33,8 @@ export function useTextContent({ }: UseTextContentOptions): UseTextContentReturn { const textCacheRef = useRef({ content: '', - htmlLength: 0, + htmlSnapshot: '', + valid: false, timestamp: 0, }); @@ -40,7 +42,7 @@ export function useTextContent({ * Invalidate cache to force fresh content read */ const invalidateCache = useCallback(() => { - textCacheRef.current = { content: '', htmlLength: 0, timestamp: 0 }; + textCacheRef.current = { content: '', htmlSnapshot: '', valid: false, timestamp: 0 }; }, []); /** @@ -56,11 +58,14 @@ export function useTextContent({ if (!editableRef.current) return ''; // Performance optimization: Check cache validity - const currentHtmlLength = editableRef.current.innerHTML.length; + // Comparing only innerHTML.length allows different DOM states with the same + // size to reuse stale text. File icon SVG sizes make that collision depend + // on the referenced extension, which is especially confusing for users. + const currentHtml = editableRef.current.innerHTML; const cache = textCacheRef.current; - // Return cached content if HTML hasn't changed (simple dirty check) - if (currentHtmlLength === cache.htmlLength && cache.content !== '') { + // Return cached content only when the exact DOM snapshot is unchanged. + if (cache.valid && currentHtml === cache.htmlSnapshot) { timer.mark('cache-hit'); timer.end(); return cache.content; @@ -135,7 +140,8 @@ export function useTextContent({ // Update cache textCacheRef.current = { content: text, - htmlLength: currentHtmlLength, + htmlSnapshot: currentHtml, + valid: true, timestamp: Date.now(), }; diff --git a/webview/src/components/ChatInputBox/hooks/useTooltip.ts b/webview/src/components/ChatInputBox/hooks/useTooltip.ts index 11fde594..586a8605 100644 --- a/webview/src/components/ChatInputBox/hooks/useTooltip.ts +++ b/webview/src/components/ChatInputBox/hooks/useTooltip.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState, type RefObject } from 'react'; import { getAppViewport } from '../../../utils/viewport'; export interface TooltipState { @@ -21,6 +21,11 @@ interface UseTooltipReturn { handleMouseLeave: () => void; } +interface UseTooltipOptions { + /** Element whose DOM replacement can invalidate the visible tooltip. */ + containerRef?: RefObject; +} + const TOOLTIP_TARGET_SELECTOR = '.file-tag.has-tooltip, .quote-tag.has-tooltip, .context-tool-btn.has-tooltip, .enhance-prompt-button.has-tooltip'; @@ -31,9 +36,30 @@ const TOOLTIP_TARGET_SELECTOR = * with smart positioning to avoid viewport overflow. Uses fixed positioning to * break out of overflow constraints in the input box container. */ -export function useTooltip(): UseTooltipReturn { +export function useTooltip({ containerRef }: UseTooltipOptions = {}): UseTooltipReturn { const [tooltip, setTooltip] = useState(null); + // File tags are rebuilt with innerHTML. If the pointer remains over the same + // visual location, no new mouseover event is guaranteed, so the old tooltip + // text can otherwise survive after its tag has been replaced. + useEffect(() => { + const container = containerRef?.current; + if (!container || typeof MutationObserver === 'undefined') return; + + const observer = new MutationObserver(() => { + setTooltip(null); + }); + observer.observe(container, { + attributes: true, + attributeFilter: ['data-file-path', 'data-tooltip'], + characterData: true, + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); + }, [containerRef]); + /** * Handle mouse over to show tooltip (small floating popup style) */ diff --git a/webview/src/components/ChatInputBox/utils/fileReferences.test.ts b/webview/src/components/ChatInputBox/utils/fileReferences.test.ts new file mode 100644 index 00000000..0ce12c18 --- /dev/null +++ b/webview/src/components/ChatInputBox/utils/fileReferences.test.ts @@ -0,0 +1,67 @@ +import { + parseExplicitFileReferences, + parseLineFileReference, + registerAbsoluteFileReference, + registerLineFileReference, +} from './fileReferences.js'; + +describe('file reference helpers', () => { + it('parses Windows paths with spaces and multiple explicit references', () => { + expect(parseExplicitFileReferences( + '@C:\\Program Files\\demo\\view file.xml @D:\\workspace\\index.vue' + )).toEqual([ + 'C:\\Program Files\\demo\\view file.xml', + 'D:\\workspace\\index.vue', + ]); + }); + + it('supports Unix and UNC absolute paths', () => { + expect(parseExplicitFileReferences('@\\\\server\\share\\view file.xml')).toEqual([ + '\\\\server\\share\\view file.xml', + ]); + expect(parseExplicitFileReferences('@/workspace/src/App.ts')).toEqual([ + '/workspace/src/App.ts', + ]); + }); + + it('rejects mixed text, email-like text, and annotations', () => { + expect(parseExplicitFileReferences('See @/workspace/src/App.ts')).toBeNull(); + expect(parseExplicitFileReferences('@C:\\workspace\\view.xml please review')).toBeNull(); + expect(parseExplicitFileReferences('@/workspace/src/App.vue explain this file')).toBeNull(); + expect(parseExplicitFileReferences('@user@example.com')).toBeNull(); + expect(parseExplicitFileReferences('@GetMapping("/api")')).toBeNull(); + expect(parseExplicitFileReferences('@C:\\workspace\\view.xml\nconst value = 1')).toBeNull(); + }); + + it('registers full paths and strict line references for exact rendering', () => { + const mapping = new Map(); + + expect(registerAbsoluteFileReference(mapping, 'C:\\Program Files\\view file.xml')) + .toBe('C:\\Program Files\\view file.xml'); + expect(mapping.get('C:\\Program Files\\view file.xml')) + .toBe('C:\\Program Files\\view file.xml'); + expect(mapping.get('view file.xml')).toBe('C:\\Program Files\\view file.xml'); + + expect(parseLineFileReference('@C:\\Program Files\\Main.java#L10-12')).toEqual({ + path: 'C:\\Program Files\\Main.java', + reference: 'C:\\Program Files\\Main.java#L10-12', + }); + expect(registerLineFileReference(mapping, '@C:\\Program Files\\Main.java#L10-12')) + .toBe('C:\\Program Files\\Main.java#L10-12'); + expect(mapping.get('C:\\Program Files\\Main.java#L10-12')) + .toBe('C:\\Program Files\\Main.java'); + }); + + it('keeps an at sign inside a structured absolute path', () => { + const mapping = new Map(); + const filePath = 'C:\\workspace\\user@example.vue'; + + expect(registerAbsoluteFileReference(mapping, filePath)).toBe(filePath); + expect(mapping.get('user@example.vue')).toBe(filePath); + }); + + it('rejects invalid zero and reversed line ranges', () => { + expect(parseLineFileReference('@C:\\workspace\\Main.java#L0')).toBeNull(); + expect(parseLineFileReference('@C:\\workspace\\Main.java#L12-10')).toBeNull(); + }); +}); diff --git a/webview/src/components/ChatInputBox/utils/fileReferences.ts b/webview/src/components/ChatInputBox/utils/fileReferences.ts new file mode 100644 index 00000000..6ff8b65e --- /dev/null +++ b/webview/src/components/ChatInputBox/utils/fileReferences.ts @@ -0,0 +1,160 @@ +/** + * Shared parsing and registration helpers for absolute file references. + * + * A file reference is deliberately stricter than an arbitrary token that + * happens to start with `@`. Callers must register the complete path before + * `useFileTags` is allowed to render it as a file tag. + */ + +export type FilePathMapping = Map; + +const ABSOLUTE_FILE_PATH_PATTERN = /^(?:[a-zA-Z]:[\\/]|\\\\[^\\/\r\n]+[\\/][^\\/\r\n]+|\/)/; +const NEXT_EXPLICIT_REFERENCE_PATTERN = /\s+@(?=(?:[a-zA-Z]:[\\/]|\\\\|\/))/g; +const LINE_REFERENCE_PATTERN = /^@(.+)#L(\d+)(?:-(\d+))?$/; +const COMMON_EXTENSIONLESS_FILE_NAMES = new Set([ + 'dockerfile', + 'gemfile', + 'license', + 'makefile', + 'procfile', + 'readme', +]); + +/** + * Normalize one absolute path, accepting an optional leading `@` for text + * payloads. Newlines are rejected; an `@` inside a structured path remains a + * valid filename character. Text parsers apply their own stricter marker rule. + */ +export function normalizeAbsoluteFilePath(input: string): string | null { + if (typeof input !== 'string') return null; + + const trimmed = input.trim(); + const path = trimmed.startsWith('@') ? trimmed.slice(1).trim() : trimmed; + if (!path || /[\r\n]/.test(path)) return null; + return ABSOLUTE_FILE_PATH_PATTERN.test(path) ? path : null; +} + +function getFileName(filePath: string): string { + const withoutTrailingSeparators = filePath.replace(/[\\/]+$/, ''); + return withoutTrailingSeparators.split(/[/\\]/).pop() || withoutTrailingSeparators; +} + +/** + * Clipboard text has no structured end marker for a path with spaces. Keep + * promotion conservative by requiring a filename-like final segment. This + * accepts normal extensions and common extensionless project files while + * rejecting typical mixed payloads such as `@C:\\view.xml please review`. + */ +function isPlausiblePastedFilePath(filePath: string): boolean { + const fileName = getFileName(filePath); + if (!fileName) return false; + + const lastDot = fileName.lastIndexOf('.'); + if (lastDot >= 0 && lastDot < fileName.length - 1) { + return !/\s/.test(fileName.slice(lastDot + 1)); + } + + return COMMON_EXTENSIONLESS_FILE_NAMES.has(fileName.toLowerCase()); +} + +/** + * Register both the complete path and its display name for exact tag lookup. + * Returns the normalized path when registration succeeds. + */ +export function registerAbsoluteFileReference( + pathMapping: FilePathMapping, + input: string, +): string | null { + const filePath = normalizeAbsoluteFilePath(input); + if (!filePath) return null; + + pathMapping.set(filePath, filePath); + const fileName = getFileName(filePath); + if (fileName) { + pathMapping.set(fileName, filePath); + } + return filePath; +} + +/** + * Parse a complete clipboard payload made only of explicit `@` absolute + * references. A following `@` that starts another absolute path is the only + * path separator that is inferred; all other text makes the payload invalid. + */ +export function parseExplicitFileReferences(input: string): string[] | null { + if (typeof input !== 'string') return null; + + const text = input.trim(); + if (!text || !text.startsWith('@')) return null; + + const starts = [0]; + for (const match of text.matchAll(NEXT_EXPLICIT_REFERENCE_PATTERN)) { + starts.push(match.index + match[0].length - 1); + } + + const paths: string[] = []; + for (let index = 0; index < starts.length; index++) { + const start = starts[index]; + const end = starts[index + 1] ?? text.length; + const rawPath = text.slice(start + 1, end).trim(); + + // A nested @ or a newline inside a segment means this is mixed/ordinary + // text, not a sequence of explicit file references. + if (!rawPath || rawPath.includes('@') || /[\r\n]/.test(rawPath)) { + return null; + } + + const filePath = normalizeAbsoluteFilePath(rawPath); + if (!filePath || !isPlausiblePastedFilePath(filePath)) return null; + paths.push(filePath); + } + + return paths.length > 0 ? paths : null; +} + +export interface LineFileReference { + path: string; + reference: string; +} + +/** + * Parse only the strict single-reference form used by editor selections: + * `@#L` or `@#L-`. + */ +export function parseLineFileReference(input: string): LineFileReference | null { + if (typeof input !== 'string') return null; + + const match = input.trim().match(LINE_REFERENCE_PATTERN); + if (!match) return null; + + const path = normalizeAbsoluteFilePath(match[1]); + if (!path) return null; + + const startLine = Number(match[2]); + const endLine = match[3] ? Number(match[3]) : undefined; + if ( + !Number.isSafeInteger(startLine) + || startLine < 1 + || (endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine < startLine)) + ) { + return null; + } + + return { + path, + reference: `${path}#L${startLine}${endLine === undefined ? '' : `-${endLine}`}`, + }; +} + +/** Register a strict line-number reference and its underlying absolute path. */ +export function registerLineFileReference( + pathMapping: FilePathMapping, + input: string, +): string | null { + const parsed = parseLineFileReference(input); + if (!parsed) return null; + + registerAbsoluteFileReference(pathMapping, parsed.path); + pathMapping.set(parsed.reference, parsed.path); + return parsed.reference; +} diff --git a/webview/src/global.d.ts b/webview/src/global.d.ts index 8de2455d..4cf8d28f 100644 --- a/webview/src/global.d.ts +++ b/webview/src/global.d.ts @@ -23,7 +23,13 @@ interface Window { getClipboardFilePath?: () => Promise; /** - * Handle file path(s) dropped from Java (supports batch files) + * Insert structured absolute file references from Java or another IDE + * integration. The array form preserves spaces inside each path. + */ + insertFileReferencesAtCursor?: (filePathInput: string | string[]) => void; + + /** + * Legacy file-path callback retained for older integrations. */ handleFilePathFromJava?: (filePathInput: string | string[]) => void;