mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-30 09:02:56 +08:00
Clicking a file reference in a chat message (e.g. `useShellTerminal.ts`) opened a new browser window because it was rendered as a plain anchor with target="_blank" and an empty/relative href. The markdown link renderer now intercepts file-path links — using the href, or the link text when the href is empty — strips any `:line:col` suffix, and opens the file in the in-app editor side panel while keeping the Chat tab active (matching the inline edit view). - useFileOpenResolver: resolves bare/partial references to real project files via the cached project file tree - PaletteOpsContext: add `openFileInEditor` op that opens the editor without switching tabs
109 lines
3.1 KiB
TypeScript
109 lines
3.1 KiB
TypeScript
import { useCallback, useRef } from 'react';
|
|
|
|
import { api } from '../utils/api';
|
|
import type { Project } from '../types/app';
|
|
|
|
type FileNode = {
|
|
type: 'file' | 'directory';
|
|
name: string;
|
|
path: string;
|
|
children?: FileNode[];
|
|
};
|
|
|
|
type FlatFile = {
|
|
name: string;
|
|
path: string;
|
|
};
|
|
|
|
// `diffInfo` is intentionally `any` so this resolver can wrap editor handlers
|
|
// that expect a concrete diff payload type as well as generic callers.
|
|
type OnFileOpen = (filePath: string, diffInfo?: any) => void;
|
|
|
|
const normalize = (value: string): string => value.replace(/\\/g, '/');
|
|
|
|
const flatten = (nodes: FileNode[], out: FlatFile[]): void => {
|
|
for (const node of nodes) {
|
|
if (node.type === 'file') {
|
|
out.push({ name: node.name, path: node.path });
|
|
} else if (node.children && node.children.length > 0) {
|
|
flatten(node.children, out);
|
|
}
|
|
}
|
|
};
|
|
|
|
// References inside chat messages are often bare basenames (`foo.ts`) or partial
|
|
// paths (`utils/foo.ts`) rather than full paths, so match by path suffix and
|
|
// fall back to filename equality.
|
|
const findBestMatch = (files: FlatFile[], ref: string): string | null => {
|
|
const target = normalize(ref).replace(/^\.\//, '').replace(/^\/+/, '');
|
|
if (!target) {
|
|
return null;
|
|
}
|
|
|
|
const suffixMatch = files.find((file) => {
|
|
const filePath = normalize(file.path);
|
|
return filePath === target || filePath.endsWith(`/${target}`);
|
|
});
|
|
if (suffixMatch) {
|
|
return suffixMatch.path;
|
|
}
|
|
|
|
const base = target.split('/').pop() || target;
|
|
return files.find((file) => file.name === base)?.path ?? null;
|
|
};
|
|
|
|
/**
|
|
* Wraps an `onFileOpen` handler so a possibly bare/partial file reference is
|
|
* resolved against the project's file tree (cached per project) before the file
|
|
* is opened in the in-app editor.
|
|
*/
|
|
export function useFileOpenResolver(
|
|
selectedProject: Project | null | undefined,
|
|
onFileOpen: OnFileOpen,
|
|
): OnFileOpen {
|
|
const projectId = selectedProject?.projectId;
|
|
const cacheRef = useRef<{ projectId?: string; files: Promise<FlatFile[]> | null }>({
|
|
projectId: undefined,
|
|
files: null,
|
|
});
|
|
|
|
const loadFiles = useCallback((): Promise<FlatFile[]> => {
|
|
if (!projectId) {
|
|
return Promise.resolve([]);
|
|
}
|
|
if (cacheRef.current.projectId === projectId && cacheRef.current.files) {
|
|
return cacheRef.current.files;
|
|
}
|
|
|
|
const filesPromise = (async () => {
|
|
try {
|
|
const response = await api.getFiles(projectId);
|
|
if (!response.ok) {
|
|
return [];
|
|
}
|
|
const data = await response.json();
|
|
const tree: FileNode[] = Array.isArray(data) ? data : [];
|
|
const flat: FlatFile[] = [];
|
|
flatten(tree, flat);
|
|
return flat;
|
|
} catch {
|
|
return [];
|
|
}
|
|
})();
|
|
|
|
cacheRef.current = { projectId, files: filesPromise };
|
|
return filesPromise;
|
|
}, [projectId]);
|
|
|
|
return useCallback(
|
|
(filePath: string, diffInfo?: any) => {
|
|
const ref = normalize(filePath).trim();
|
|
void loadFiles().then((files) => {
|
|
const match = findBestMatch(files, ref);
|
|
onFileOpen(match ?? filePath, diffInfo);
|
|
});
|
|
},
|
|
[loadFiles, onFileOpen],
|
|
);
|
|
}
|