mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(web-shell): Add workspace file browser and previews * fix(web-shell): Restore source preview CI fixtures and notices * fix(web-shell): Address file pane review blockers * fix(web-shell): Resolve review regressions and strengthen file checks --------- Co-authored-by: 钉萁 <[email protected]>
558 lines
18 KiB
TypeScript
558 lines
18 KiB
TypeScript
import { safeDisplayText } from '../../hooks/useAtMentionSources';
|
|
import { usePanelRef } from 'react-resizable-panels';
|
|
import { FileTree, type ContextMenuOpenContext } from '@pierre/trees';
|
|
import {
|
|
Code2Icon,
|
|
EyeIcon,
|
|
FolderIcon,
|
|
RefreshCwIcon,
|
|
SearchIcon,
|
|
} from 'lucide-react';
|
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { useI18n } from '../../i18n';
|
|
import { useTheme } from '../../themeContext';
|
|
import type { DaemonDirectoryEntry } from '@qwen-code/web-shell/daemon-react-sdk';
|
|
import type { ArtifactWorkspaceActions } from './useArtifactWorkspaceTarget';
|
|
import { Button } from '../ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
} from '../ui/dropdown-menu';
|
|
import {
|
|
downloadWorkspaceFile,
|
|
getImageMimeTypeFromPath,
|
|
isExcelFile,
|
|
} from './artifactUtils';
|
|
import { writeClipboardText } from '../../utils/clipboard';
|
|
import { Input } from '../ui/input';
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from '../ui/resizable';
|
|
import styles from './WorkspaceFileBrowser.module.css';
|
|
|
|
interface DirectoryState {
|
|
path: string;
|
|
error?: string;
|
|
empty?: boolean;
|
|
truncated?: boolean;
|
|
}
|
|
|
|
interface WorkspaceFileBrowserProps {
|
|
workspaceCwd: string;
|
|
actions: ArtifactWorkspaceActions;
|
|
onOpenPreview: (path: string) => void;
|
|
onReferenceFile?: (path: string) => void;
|
|
renderPreview: (path: string, source: boolean, version: number) => ReactNode;
|
|
}
|
|
|
|
export function WorkspaceFileBrowser({
|
|
workspaceCwd,
|
|
actions,
|
|
renderPreview,
|
|
onOpenPreview,
|
|
onReferenceFile,
|
|
}: WorkspaceFileBrowserProps) {
|
|
const { t } = useI18n();
|
|
const theme = useTheme();
|
|
const treePanelRef = usePanelRef();
|
|
const contentPanelRef = usePanelRef();
|
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
const treeLabelRef = useRef(t('files.title'));
|
|
treeLabelRef.current = t('files.title');
|
|
const treeRef = useRef<FileTree | undefined>(undefined);
|
|
const retryRef = useRef<((path: string) => void) | undefined>(undefined);
|
|
const [selectedPath, setSelectedPath] = useState<string>();
|
|
const hasPreview = selectedPath !== undefined;
|
|
const [source, setSource] = useState(false);
|
|
const [treeVisible, setTreeVisible] = useState(true);
|
|
const [filter, setFilter] = useState('');
|
|
const [noMatches, setNoMatches] = useState(false);
|
|
const [version, setVersion] = useState(0);
|
|
const [directories, setDirectories] = useState<DirectoryState[]>([]);
|
|
const [menu, setMenu] = useState<{
|
|
path: string;
|
|
context: ContextMenuOpenContext;
|
|
}>();
|
|
const [actionError, setActionError] = useState<string>();
|
|
const lifetimeRef = useRef<object | undefined>(undefined);
|
|
// Pierre restores focus synchronously and can reenter Radix dismissal.
|
|
const closingMenuRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!hasPreview) return undefined;
|
|
// Wait for the group to register the nonzero preview constraints.
|
|
const frame = requestAnimationFrame(() =>
|
|
contentPanelRef.current?.resize('70%'),
|
|
);
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [hasPreview, contentPanelRef]);
|
|
|
|
useEffect(() => {
|
|
const host = hostRef.current;
|
|
if (!host) return undefined;
|
|
let disposed = false;
|
|
const lifetime = {};
|
|
lifetimeRef.current = lifetime;
|
|
setMenu(undefined);
|
|
const entries = new Map<string, DaemonDirectoryEntry>();
|
|
const cache = new Map<string, DirectoryState>();
|
|
const tree = new FileTree({
|
|
paths: [],
|
|
initialExpansion: 'closed',
|
|
flattenEmptyDirectories: false,
|
|
icons: 'complete',
|
|
unsafeCSS: `
|
|
[data-file-tree-virtualized-scroll] { overflow: auto; }
|
|
[data-file-tree-virtualized-list] {
|
|
width: max-content;
|
|
min-width: 100%;
|
|
}
|
|
[data-item-section="content"] {
|
|
min-width: max-content;
|
|
flex-shrink: 0;
|
|
}
|
|
[data-item-section="spacing"] { flex-shrink: 0; }
|
|
`,
|
|
// The host supplies the visible filter instead of Pierre's search input.
|
|
search: false,
|
|
searchFakeFocus: true,
|
|
searchBlurBehavior: 'retain',
|
|
fileTreeSearchMode: 'hide-non-matches',
|
|
composition: {
|
|
contextMenu: {
|
|
triggerMode: 'right-click',
|
|
onOpen(item, context) {
|
|
const entry = entries.get(item.path);
|
|
if (entry?.kind === 'file' || entry?.kind === 'symlink') {
|
|
closingMenuRef.current = false;
|
|
setActionError(undefined);
|
|
setMenu({ path: item.path, context });
|
|
} else {
|
|
context.close();
|
|
}
|
|
},
|
|
onClose() {
|
|
closingMenuRef.current = true;
|
|
if (!disposed) setMenu(undefined);
|
|
},
|
|
},
|
|
},
|
|
onSearchChange(value) {
|
|
// Selection and Escape close search; normalized queries must not edit input.
|
|
if (value === null) setFilter('');
|
|
},
|
|
onSelectionChange(paths) {
|
|
const path = paths.at(-1);
|
|
const entry = path ? entries.get(path) : undefined;
|
|
if (path && (entry?.kind === 'file' || entry?.kind === 'symlink')) {
|
|
setSelectedPath(path);
|
|
setSource(false);
|
|
}
|
|
},
|
|
});
|
|
treeRef.current = tree;
|
|
setDirectories([]);
|
|
setNoMatches(false);
|
|
tree.render({ fileTreeContainer: host });
|
|
const shadow = tree.getFileTreeContainer()?.shadowRoot;
|
|
const labelTree = () =>
|
|
shadow
|
|
?.querySelector('[role="tree"]')
|
|
?.setAttribute('aria-label', treeLabelRef.current);
|
|
const observer = new MutationObserver(labelTree);
|
|
if (shadow) observer.observe(shadow, { childList: true, subtree: true });
|
|
labelTree();
|
|
|
|
const publish = () => {
|
|
if (!disposed) setDirectories([...cache.values()]);
|
|
};
|
|
const load = async (path: string) => {
|
|
if (disposed || cache.has(path)) return;
|
|
cache.set(path, { path });
|
|
publish();
|
|
try {
|
|
const listing = await actions.listDirectory(path || '.');
|
|
if (disposed) return;
|
|
cache.set(path, {
|
|
path,
|
|
empty: listing.entries.length === 0,
|
|
truncated: listing.truncated,
|
|
});
|
|
const added = listing.entries.map((entry) => {
|
|
const child = `${path ? `${path}/` : ''}${entry.name}${entry.kind === 'directory' ? '/' : ''}`;
|
|
entries.set(child, entry);
|
|
return { type: 'add' as const, path: child };
|
|
});
|
|
if (added.length) tree.batch(added);
|
|
} catch (error) {
|
|
if (disposed) return;
|
|
cache.set(path, {
|
|
path,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
publish();
|
|
};
|
|
const unsubscribe = tree.subscribe(() => {
|
|
const searching = Boolean(tree.getSearchValue());
|
|
setNoMatches(searching && tree.getSearchMatchingPaths().length === 0);
|
|
// Search expands matching ancestors without requesting their contents.
|
|
if (searching) return;
|
|
for (const [path, entry] of entries) {
|
|
if (entry.kind !== 'directory') continue;
|
|
const item = tree.getItem(path);
|
|
if (item && 'isExpanded' in item && item.isExpanded()) {
|
|
void load(path.slice(0, -1));
|
|
}
|
|
}
|
|
});
|
|
retryRef.current = (path) => {
|
|
cache.delete(path);
|
|
void load(path);
|
|
};
|
|
void load('');
|
|
return () => {
|
|
disposed = true;
|
|
if (lifetimeRef.current === lifetime) lifetimeRef.current = undefined;
|
|
observer.disconnect();
|
|
unsubscribe();
|
|
tree.cleanUp();
|
|
host.replaceChildren();
|
|
treeRef.current = undefined;
|
|
retryRef.current = undefined;
|
|
};
|
|
}, [actions, version]);
|
|
|
|
useEffect(() => {
|
|
treeRef.current?.setSearch(filter || null);
|
|
}, [filter, version, actions]);
|
|
|
|
useEffect(() => {
|
|
treeRef.current
|
|
?.getFileTreeContainer()
|
|
?.shadowRoot?.querySelector('[role="tree"]')
|
|
?.setAttribute('aria-label', t('files.title'));
|
|
}, [t]);
|
|
|
|
const runMenuAction = async (
|
|
action: () => void | Promise<void>,
|
|
transferFocus = false,
|
|
) => {
|
|
const lifetime = lifetimeRef.current;
|
|
closingMenuRef.current = true;
|
|
menu?.context.close({ restoreFocus: !transferFocus });
|
|
setMenu(undefined);
|
|
try {
|
|
await action();
|
|
} catch (error) {
|
|
if (lifetimeRef.current === lifetime) {
|
|
setActionError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
};
|
|
|
|
const separator = /^(?:[a-z]:[\\/]|\\\\)/i.test(workspaceCwd) ? '\\' : '/';
|
|
const absolutePath = (path: string) =>
|
|
path
|
|
? `${workspaceCwd.replace(separator === '\\' ? /[\\/]+$/ : /\/+$/, '')}${separator}${path.replaceAll('/', separator)}`
|
|
: workspaceCwd;
|
|
const absoluteMenuPath = absolutePath(menu?.path ?? '');
|
|
const canPreview =
|
|
selectedPath && /\.(?:md|markdown|html?)$/i.test(selectedPath);
|
|
const notices = directories.filter(
|
|
(directory) => directory.error || directory.empty || directory.truncated,
|
|
);
|
|
return (
|
|
<section
|
|
className={styles.browser}
|
|
data-testid="workspace-file-browser"
|
|
style={{ colorScheme: theme }}
|
|
>
|
|
{menu && (
|
|
<DropdownMenu
|
|
open
|
|
modal={false}
|
|
onOpenChange={(open) => {
|
|
if (!open && !closingMenuRef.current) {
|
|
closingMenuRef.current = true;
|
|
menu.context.close();
|
|
setMenu(undefined);
|
|
}
|
|
}}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<span
|
|
aria-hidden
|
|
tabIndex={-1}
|
|
style={{
|
|
position: 'fixed',
|
|
left: menu.context.anchorRect.left,
|
|
top: menu.context.anchorRect.top,
|
|
width: 0,
|
|
height: 0,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
className="w-48"
|
|
aria-label={`${t('files.title')}: ${safeDisplayText(menu.path)}`}
|
|
aria-labelledby={undefined}
|
|
data-file-tree-context-menu-root="true"
|
|
onCloseAutoFocus={(event) => {
|
|
event.preventDefault();
|
|
menu.context.restoreFocus();
|
|
}}
|
|
>
|
|
{(/\.(?:md|markdown|html?)$/i.test(menu.path) ||
|
|
getImageMimeTypeFromPath(menu.path) ||
|
|
isExcelFile(menu.path)) && (
|
|
<DropdownMenuItem
|
|
onSelect={() =>
|
|
void runMenuAction(() => onOpenPreview(menu.path), true)
|
|
}
|
|
>
|
|
{t('attachment.showPreview')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
disabled={!onReferenceFile}
|
|
onSelect={() =>
|
|
void runMenuAction(() => onReferenceFile?.(menu.path), true)
|
|
}
|
|
>
|
|
{t('files.reference')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() =>
|
|
void runMenuAction(() => writeClipboardText(menu.path))
|
|
}
|
|
>
|
|
{t('files.copyRelativePath')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() =>
|
|
void runMenuAction(() => writeClipboardText(absoluteMenuPath))
|
|
}
|
|
>
|
|
{t('files.copyAbsolutePath')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
const lifetime = lifetimeRef.current;
|
|
void runMenuAction(() =>
|
|
downloadWorkspaceFile(
|
|
actions,
|
|
menu.path,
|
|
undefined,
|
|
() => lifetimeRef.current !== lifetime,
|
|
),
|
|
);
|
|
}}
|
|
>
|
|
{t('common.download')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
<header className={styles.toolbar}>
|
|
<span
|
|
className={styles.path}
|
|
title={safeDisplayText(absolutePath(selectedPath ?? ''))}
|
|
>
|
|
{safeDisplayText(workspaceCwd.split(/[\\/]/).filter(Boolean).at(-1))}
|
|
{selectedPath && <> / {safeDisplayText(selectedPath)}</>}
|
|
</span>
|
|
{canPreview && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSource((value) => !value)}
|
|
>
|
|
{source ? <EyeIcon /> : <Code2Icon />}
|
|
{t(source ? 'attachment.showPreview' : 'attachment.showSource')}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setVersion((value) => value + 1)}
|
|
aria-label={t('common.refresh')}
|
|
title={t('common.refresh')}
|
|
>
|
|
<RefreshCwIcon />
|
|
</Button>
|
|
{hasPreview && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() =>
|
|
treeVisible
|
|
? treePanelRef.current?.collapse()
|
|
: treePanelRef.current?.expand()
|
|
}
|
|
aria-label={t('files.toggleTree')}
|
|
title={t('files.toggleTree')}
|
|
aria-pressed={treeVisible}
|
|
>
|
|
<FolderIcon />
|
|
</Button>
|
|
)}
|
|
</header>
|
|
{actionError && (
|
|
<div className={styles.notices} role="alert">
|
|
{actionError}
|
|
</div>
|
|
)}
|
|
<ResizablePanelGroup
|
|
className={styles.columns}
|
|
orientation="horizontal"
|
|
// Drawer pointer capture would retarget clicks out of Pierre's shadow DOM.
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
<ResizablePanel
|
|
defaultSize="0%"
|
|
minSize={hasPreview ? 120 : 0}
|
|
maxSize={hasPreview ? '100%' : '0%'}
|
|
panelRef={contentPanelRef}
|
|
>
|
|
<div
|
|
className={styles.content}
|
|
data-testid="workspace-file-content"
|
|
hidden={!hasPreview}
|
|
>
|
|
{selectedPath && renderPreview(selectedPath, source, version)}
|
|
</div>
|
|
</ResizablePanel>
|
|
<ResizableHandle
|
|
className={styles.splitHandle}
|
|
hidden={!hasPreview || !treeVisible}
|
|
aria-label={t('files.resizeTree')}
|
|
/>
|
|
<ResizablePanel
|
|
defaultSize="100%"
|
|
minSize={150}
|
|
maxSize={hasPreview ? '70%' : '100%'}
|
|
collapsible
|
|
panelRef={treePanelRef}
|
|
onResize={(size) => setTreeVisible(size.asPercentage > 0)}
|
|
>
|
|
<div
|
|
className={styles.sidebar}
|
|
hidden={!treeVisible}
|
|
data-testid="workspace-file-sidebar"
|
|
>
|
|
<div className={styles.search}>
|
|
<SearchIcon aria-hidden />
|
|
<Input
|
|
data-workspace-file-filter
|
|
value={filter}
|
|
onChange={(event) => setFilter(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
event.key !== 'Escape' ||
|
|
!filter ||
|
|
event.nativeEvent.isComposing ||
|
|
event.nativeEvent.keyCode === 229
|
|
)
|
|
return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setFilter('');
|
|
}}
|
|
placeholder={t('files.filter')}
|
|
aria-label={t('files.filter')}
|
|
/>
|
|
</div>
|
|
<div
|
|
ref={hostRef}
|
|
className={styles.tree}
|
|
hidden={noMatches}
|
|
onClickCapture={(event) => {
|
|
const row = event.nativeEvent
|
|
.composedPath()
|
|
.find(
|
|
(node) =>
|
|
node instanceof HTMLElement &&
|
|
node.getAttribute('role') === 'treeitem',
|
|
);
|
|
if (!(row instanceof HTMLElement) || row.matches(':focus'))
|
|
return;
|
|
const path = row.getAttribute('data-item-path');
|
|
const root = row.getRootNode();
|
|
const lifetime = lifetimeRef.current;
|
|
if (!path || !(root instanceof ShadowRoot)) return;
|
|
// Pierre can reuse the clicked DOM row before bubbling finishes.
|
|
requestAnimationFrame(() => {
|
|
if (lifetimeRef.current !== lifetime) return;
|
|
root
|
|
.querySelector<HTMLElement>(
|
|
`[role="treeitem"][data-item-path="${CSS.escape(path)}"]`,
|
|
)
|
|
?.focus({ preventScroll: true });
|
|
});
|
|
}}
|
|
onMouseDownCapture={(event) => {
|
|
// Native focus can scroll virtual rows before the click lands.
|
|
if (
|
|
event.button === 0 &&
|
|
event.nativeEvent
|
|
.composedPath()
|
|
.some(
|
|
(node) =>
|
|
node instanceof HTMLElement &&
|
|
node.getAttribute('role') === 'treeitem',
|
|
)
|
|
) {
|
|
event.preventDefault();
|
|
}
|
|
}}
|
|
/>
|
|
{noMatches && (
|
|
<div className={styles.noMatches} role="status">
|
|
{t('files.noMatches')}
|
|
</div>
|
|
)}
|
|
{notices.length > 0 && (
|
|
<div className={styles.notices}>
|
|
{notices.map((directory) => (
|
|
<div key={directory.path}>
|
|
<span>
|
|
{directory.path
|
|
? safeDisplayText(directory.path)
|
|
: t('files.title')}
|
|
:{' '}
|
|
</span>
|
|
{directory.empty && (
|
|
<span>{t('files.emptyDirectory')}</span>
|
|
)}
|
|
{directory.truncated && (
|
|
<span role="status">{t('files.truncated')}</span>
|
|
)}
|
|
{directory.error && (
|
|
<>
|
|
<span role="alert">{directory.error}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => retryRef.current?.(directory.path)}
|
|
>
|
|
{t('common.retry')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</section>
|
|
);
|
|
}
|