mirror of
https://github.com/zhukunpenglinyutong/jetbrains-cc-gui.git
synced 2026-08-28 16:31:27 +08:00
Merge pull request #1727 from elexiang/codex/fix-file-reference-boundaries-v054
fix(input): preserve text between external file references
This commit is contained in:
+16
-13
@@ -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<String> 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<String> 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());
|
||||
|
||||
@@ -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<String> filePaths) {
|
||||
if (filePaths == null || filePaths.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> 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<String> 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<String> 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.
|
||||
|
||||
@@ -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<String> 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.
|
||||
|
||||
@@ -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<String> filePaths) {
|
||||
if (project == null) {
|
||||
LOG.error("project is null");
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> snapshot = new ArrayList<>();
|
||||
if (filePaths != null) {
|
||||
for (String filePath : filePaths) {
|
||||
if (filePath != null && !filePath.trim().isEmpty()) {
|
||||
snapshot.add(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (snapshot.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<List<String>> pending = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* Return the paths immediately when the frontend is ready, otherwise defer
|
||||
* the latest batch until the ready transition flushes it.
|
||||
*/
|
||||
List<String> offer(List<String> filePaths, boolean frontendReady) {
|
||||
List<String> snapshot = Collections.unmodifiableList(new ArrayList<>(filePaths));
|
||||
if (frontendReady) {
|
||||
return snapshot;
|
||||
}
|
||||
pending.updateAndGet(existing -> {
|
||||
if (existing == null) {
|
||||
return snapshot;
|
||||
}
|
||||
List<String> 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<String> takePending() {
|
||||
return pending.getAndSet(null);
|
||||
}
|
||||
}
|
||||
+57
@@ -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<String> paths = new ArrayList<>(Arrays.asList(
|
||||
"C:\\Program Files\\demo\\view file.xml",
|
||||
"\\\\server\\share\\index.vue"
|
||||
));
|
||||
|
||||
List<String> 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<String> 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());
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,9 @@ export const ChatInputBox = memo(forwardRef<ChatInputBoxHandle, ChatInputBoxProp
|
||||
}, [renderFileTags, renderQuoteTags]);
|
||||
|
||||
// Tooltip hook
|
||||
const { tooltip, handleMouseOver, handleMouseLeave } = useTooltip();
|
||||
const { tooltip, handleMouseOver, handleMouseLeave } = useTooltip({
|
||||
containerRef: editableRef,
|
||||
});
|
||||
|
||||
// Context menu hook
|
||||
const ctxMenu = useContextMenu();
|
||||
|
||||
@@ -58,6 +58,19 @@ describe('useFileTags', () => {
|
||||
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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string>() };
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<string, string>() };
|
||||
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<React.SetStateAction<Attachment[]>>,
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = '<span>alpha</span>';
|
||||
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 = '<span>bravo</span>';
|
||||
expect(result.current.getTextContent()).toBe('bravo');
|
||||
|
||||
document.body.removeChild(editable);
|
||||
});
|
||||
});
|
||||
@@ -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<TextContentCache>({
|
||||
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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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<HTMLElement | null>;
|
||||
}
|
||||
|
||||
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<TooltipState | null>(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)
|
||||
*/
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
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<string, string>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
|
||||
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:
|
||||
* `@<absolute path>#L<start>` or `@<absolute path>#L<start>-<end>`.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Vendored
+7
-1
@@ -23,7 +23,13 @@ interface Window {
|
||||
getClipboardFilePath?: () => Promise<string>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user