mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-29 16:12:53 +08:00
fix(chat): open file references in editor instead of new browser window
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
This commit is contained in:
@@ -8,12 +8,48 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
|
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
||||||
|
|
||||||
type MarkdownProps = {
|
type MarkdownProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Links to the wider web (or in-page anchors) keep normal browser navigation;
|
||||||
|
// everything else is treated as a workspace file reference.
|
||||||
|
const isExternalHref = (href?: string): boolean =>
|
||||||
|
!!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#'));
|
||||||
|
|
||||||
|
// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`).
|
||||||
|
const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, '');
|
||||||
|
|
||||||
|
// A usable file path contains a separator or a filename with an extension.
|
||||||
|
const looksLikeFilePath = (value?: string): value is string => {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cleaned = stripLineSuffix(value.trim());
|
||||||
|
if (!cleaned || cleaned === '#') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract plain text from link children so a reference rendered only as link
|
||||||
|
// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened.
|
||||||
|
const childrenToText = (children: React.ReactNode): string => {
|
||||||
|
if (typeof children === 'string' || typeof children === 'number') {
|
||||||
|
return String(children);
|
||||||
|
}
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
return children.map(childrenToText).join('');
|
||||||
|
}
|
||||||
|
if (React.isValidElement(children)) {
|
||||||
|
return childrenToText((children.props as { children?: React.ReactNode }).children);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
type CodeBlockProps = {
|
type CodeBlockProps = {
|
||||||
node?: any;
|
node?: any;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
@@ -123,11 +159,6 @@ const markdownComponents = {
|
|||||||
{children}
|
{children}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
),
|
),
|
||||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
|
||||||
<a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
||||||
table: ({ children }: { children?: React.ReactNode }) => (
|
table: ({ children }: { children?: React.ReactNode }) => (
|
||||||
<div className="my-2 overflow-x-auto">
|
<div className="my-2 overflow-x-auto">
|
||||||
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) {
|
|||||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||||
|
const { openFileInEditor } = usePaletteOps();
|
||||||
|
|
||||||
|
const components = useMemo(
|
||||||
|
() => ({
|
||||||
|
...markdownComponents,
|
||||||
|
a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => {
|
||||||
|
// Prefer the href when it is a real path; otherwise fall back to the
|
||||||
|
// link text, since models often emit `[src/foo.ts]()` with an empty href.
|
||||||
|
const linkText = childrenToText(linkChildren);
|
||||||
|
const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined;
|
||||||
|
|
||||||
|
if (fileRef && !isExternalHref(href)) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href || fileRef}
|
||||||
|
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
openFileInEditor(stripLineSuffix(fileRef));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{linkChildren}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{linkChildren}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[openFileInEditor],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
|
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
|||||||
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||||
|
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
||||||
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
import EditorSidebar from '../../code-editor/view/EditorSidebar';
|
||||||
@@ -77,6 +78,10 @@ function MainContent({
|
|||||||
isMobile,
|
isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolves bare/partial file references (e.g. links inside chat messages) to
|
||||||
|
// real project files before opening them in the in-app editor.
|
||||||
|
const resolvedFileOpen = useFileOpenResolver(selectedProject, handleFileOpen);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Identify projects by DB `projectId`; the TaskMaster context uses the
|
// Identify projects by DB `projectId`; the TaskMaster context uses the
|
||||||
// same identifier to key its internal maps.
|
// same identifier to key its internal maps.
|
||||||
@@ -121,6 +126,10 @@ function MainContent({
|
|||||||
setActiveTab('files');
|
setActiveTab('files');
|
||||||
handleFileOpen(filePath);
|
handleFileOpen(filePath);
|
||||||
},
|
},
|
||||||
|
// Opens the editor side panel in place, keeping the current tab (e.g. chat).
|
||||||
|
openFileInEditor: (filePath: string) => {
|
||||||
|
resolvedFileOpen(filePath);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { MutableRefObject, ReactNode } from 'react';
|
|||||||
|
|
||||||
export type PaletteOps = {
|
export type PaletteOps = {
|
||||||
openFile: (path: string) => void;
|
openFile: (path: string) => void;
|
||||||
|
// Opens a file in the editor side panel without changing the active tab
|
||||||
|
// (used by in-chat file links so they behave like the inline edit view).
|
||||||
|
openFileInEditor: (path: string) => void;
|
||||||
openSettings: (tab?: string) => void;
|
openSettings: (tab?: string) => void;
|
||||||
refreshProjects: () => Promise<void> | void;
|
refreshProjects: () => Promise<void> | void;
|
||||||
};
|
};
|
||||||
@@ -13,6 +16,7 @@ const PaletteOpsContext = createContext<Registry | null>(null);
|
|||||||
|
|
||||||
const defaultOps: PaletteOps = {
|
const defaultOps: PaletteOps = {
|
||||||
openFile: () => undefined,
|
openFile: () => undefined,
|
||||||
|
openFileInEditor: () => undefined,
|
||||||
openSettings: () => undefined,
|
openSettings: () => undefined,
|
||||||
refreshProjects: () => undefined,
|
refreshProjects: () => undefined,
|
||||||
};
|
};
|
||||||
@@ -27,6 +31,8 @@ export function usePaletteOps(): PaletteOps {
|
|||||||
return useMemo<PaletteOps>(
|
return useMemo<PaletteOps>(
|
||||||
() => ({
|
() => ({
|
||||||
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
|
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
|
||||||
|
openFileInEditor: (path) =>
|
||||||
|
(ref?.current.openFileInEditor ?? defaultOps.openFileInEditor)(path),
|
||||||
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
|
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
|
||||||
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
|
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
|
||||||
}),
|
}),
|
||||||
@@ -36,18 +42,20 @@ export function usePaletteOps(): PaletteOps {
|
|||||||
|
|
||||||
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
|
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
|
||||||
const ref = useContext(PaletteOpsContext);
|
const ref = useContext(PaletteOpsContext);
|
||||||
const { openFile, openSettings, refreshProjects } = partial;
|
const { openFile, openFileInEditor, openSettings, refreshProjects } = partial;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref) return undefined;
|
if (!ref) return undefined;
|
||||||
const prev = { ...ref.current };
|
const prev = { ...ref.current };
|
||||||
if (openFile) ref.current.openFile = openFile;
|
if (openFile) ref.current.openFile = openFile;
|
||||||
|
if (openFileInEditor) ref.current.openFileInEditor = openFileInEditor;
|
||||||
if (openSettings) ref.current.openSettings = openSettings;
|
if (openSettings) ref.current.openSettings = openSettings;
|
||||||
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
|
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
|
||||||
return () => {
|
return () => {
|
||||||
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
|
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
|
||||||
|
if (openFileInEditor && ref.current.openFileInEditor === openFileInEditor) ref.current.openFileInEditor = prev.openFileInEditor;
|
||||||
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
|
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
|
||||||
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
|
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
|
||||||
};
|
};
|
||||||
}, [ref, openFile, openSettings, refreshProjects]);
|
}, [ref, openFile, openFileInEditor, openSettings, refreshProjects]);
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/hooks/useFileOpenResolver.ts
Normal file
108
src/hooks/useFileOpenResolver.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user