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:
朱昆鹏
2026-08-24 19:22:13 +08:00
committed by GitHub
19 changed files with 1046 additions and 87 deletions
@@ -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);
}
}
@@ -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;
}
+7 -1
View File
@@ -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;