feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取
文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)
文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染
全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步
验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
This commit is contained in:
+86
-84
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* The files window's tree surface: a global file-name search box on top
|
||||
* (300ms debounce; an in-flight search is aborted by the next keystroke)
|
||||
* over either the shared controlled FileTree (empty query) or the flat
|
||||
* result list (relative paths; click opens through the caller's mode-aware
|
||||
* open). Owns its refresh tick: the icon next to the search input clears
|
||||
* the tree cache. EditorHost docks it as the tab's right panel (wrapped in
|
||||
* a drag-resize handle) and provides the file context-menu open escapes.
|
||||
* over the shared controlled FileTree. The results panel and the tree are
|
||||
* BOTH mounted: the query only parks one of them (`hidden`), so clearing the
|
||||
* search is free — the tree keeps its level cache and the live directory
|
||||
* watcher (the old conditional render dropped both and refetched the whole
|
||||
* visible set on every query change). Owns its refresh tick: the icon next to
|
||||
* the search input clears the tree cache. EditorHost docks it as the tab's
|
||||
* right panel (wrapped in a drag-resize handle) and provides the file
|
||||
* context-menu open escapes.
|
||||
*
|
||||
* Uploads (header pickers, the tree's drag-drop and "upload here" menu)
|
||||
* all funnel through here: one session at a time, shown in a full-window
|
||||
@@ -23,9 +26,10 @@ import type { BetterSidebarService } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { FileTree } from './FileTree.tsx'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import type { OpenInApp } from './open-in-app.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './paths.ts'
|
||||
import { IconButton } from './ui/index.ts'
|
||||
import { UploadOverlay } from './UploadOverlay.tsx'
|
||||
import {
|
||||
summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir,
|
||||
@@ -56,25 +60,27 @@ export function TreePanel(props: {
|
||||
onOpenFileNewTab?: (path: string) => void
|
||||
/** File context-menu "open to the side" (passed through to FileTree). */
|
||||
onOpenFileSide?: (path: string) => void
|
||||
/** The "open with" menu surface (passed through to FileTree; absent →
|
||||
/** The host's open-in-app handle (passed through to FileTree; absent →
|
||||
* the whole section is hidden). */
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
openWithPinned?: string[]
|
||||
openWithSsh?: boolean
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
openInApp?: OpenInApp
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
/** A tree rename landed (passed through to FileTree for tab retargeting). */
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** A tree delete landed (passed through to FileTree for tab closing). */
|
||||
onPathDeleted?: (path: string, isDir: boolean) => void
|
||||
/** Whether the owning tab is on screen: a parked tab stops the tree's git
|
||||
* polling (the shared status store pauses when nothing visible wants it). */
|
||||
visible?: boolean
|
||||
/** Full-window presentation: the panel fills its host instead of docking
|
||||
* at a fixed width. */
|
||||
full?: boolean
|
||||
/** The sidebar registry service (file-icon registrations; passed through to FileTree). */
|
||||
service?: BetterSidebarService
|
||||
}) {
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full, service } = props
|
||||
const {
|
||||
sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide,
|
||||
openInApp, onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service,
|
||||
} = props
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -153,6 +159,7 @@ export function TreePanel(props: {
|
||||
const folderInputProps = { webkitdirectory: '' } as InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const needle = query.trim()
|
||||
const searching = needle !== ''
|
||||
useEffect(() => {
|
||||
if (needle === '') {
|
||||
setResults(null)
|
||||
@@ -188,35 +195,23 @@ export function TreePanel(props: {
|
||||
spellCheck={false}
|
||||
onChange={(event) => { setQuery(event.target.value) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
<IconButton
|
||||
label={t('refresh')}
|
||||
icon={<IconRefreshOutlineRegular size={14} />}
|
||||
onClick={() => { setRefreshTick(tick => tick + 1) }}
|
||||
>
|
||||
<IconRefreshOutlineRegular size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('uploadFiles')}
|
||||
title={t('uploadFiles')}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('uploadFiles')}
|
||||
icon={<IconUploadOutline16 size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => { fileInputRef.current?.click() }}
|
||||
>
|
||||
<IconUploadOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('uploadFolder')}
|
||||
title={t('uploadFolder')}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('uploadFolder')}
|
||||
icon={<IconFolderOpenRegular size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => { folderInputRef.current?.click() }}
|
||||
>
|
||||
<IconFolderOpenRegular size={14} />
|
||||
</button>
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -242,53 +237,60 @@ export function TreePanel(props: {
|
||||
{uploadStatus !== '' && (
|
||||
<div className={clsx(css.editorSearchHint, uploadFailed && css.editorError)} title={uploadStatus}>{uploadStatus}</div>
|
||||
)}
|
||||
{needle === '' ? (
|
||||
<FileTree
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
store={store}
|
||||
expanded={expanded}
|
||||
revealed={revealed}
|
||||
onToggle={onToggle}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenFileNewTab={onOpenFileNewTab}
|
||||
onOpenFileSide={onOpenFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithPinned}
|
||||
openWithSsh={openWithSsh}
|
||||
onOpenWith={onOpenWith}
|
||||
onToggleOpenWithPin={onToggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
busy={busy}
|
||||
service={service}
|
||||
/>
|
||||
) : (
|
||||
<div className={css.explorerBody}>
|
||||
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
|
||||
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
|
||||
{error === null && results !== null && results.matches.length === 0 && (
|
||||
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
|
||||
)}
|
||||
{error === null && results !== null && results.matches.map(rel => (
|
||||
<button
|
||||
key={rel}
|
||||
type="button"
|
||||
className={css.editorSearchResult}
|
||||
title={rel}
|
||||
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
|
||||
>
|
||||
{rel}
|
||||
</button>
|
||||
))}
|
||||
{error === null && results?.truncated === true && (
|
||||
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Search results: parked (not unmounted) while the query is empty. Its
|
||||
contents render only for a live query — a parked panel must not put
|
||||
state text (the loading line) into the panel's DOM. */}
|
||||
<div
|
||||
className={clsx(css.explorerBody, !searching && css.explorerHiddenPane)}
|
||||
hidden={!searching}
|
||||
>
|
||||
{searching && (
|
||||
<>
|
||||
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
|
||||
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
|
||||
{error === null && results !== null && results.matches.length === 0 && (
|
||||
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
|
||||
)}
|
||||
{error === null && results !== null && results.matches.map(rel => (
|
||||
<button
|
||||
key={rel}
|
||||
type="button"
|
||||
className={css.editorSearchResult}
|
||||
title={rel}
|
||||
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
|
||||
>
|
||||
{rel}
|
||||
</button>
|
||||
))}
|
||||
{error === null && results?.truncated === true && (
|
||||
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* The tree stays MOUNTED while searching: its level cache and the
|
||||
directory watcher survive a query, so clearing the box is free. */}
|
||||
<FileTree
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
store={store}
|
||||
expanded={expanded}
|
||||
revealed={revealed}
|
||||
onToggle={onToggle}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenFileNewTab={onOpenFileNewTab}
|
||||
onOpenFileSide={onOpenFileSide}
|
||||
openInApp={openInApp}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
busy={busy}
|
||||
hidden={searching}
|
||||
visible={visible !== false}
|
||||
service={service}
|
||||
/>
|
||||
{upload !== null && (
|
||||
<UploadOverlay
|
||||
dir={upload.dir}
|
||||
|
||||
Reference in New Issue
Block a user