feat(sidebar): 菜单层级/性能 + 选中条吸底 + 打包进度 + 修复「在侧边打开」
用户实测反馈 5 条:
1) 文件列表性能 / 右键菜单不再刷屏(先测量后改,探针 tests/file-tree-menu-perf.spec.tsx)
- 宿主应用列表按路径缓存:N 次打开菜单只请求 1 次(原每次 1 次 + Loading 帧)
- 行上的 git 数据改为原始值(tone/letter):共享 store 每次轮询重建对象不再触达行
(探针:开窗 folderIcons 4 → 0;root 行也 memo 化)
- 菜单开关不触发目录重列(fsTree=0);实测「菜单打开期间暂停轮询」会让变更行瞬时
掉色并多两次重渲染,故按测量结果放弃该做法(数字与理由写进探针注释)
2) 「已选择 N 项」条移到底部:滚动容器最后一个内容子节点 + sticky bottom(不遮最后
一行),窄栏 flex-wrap 换行不出横向滚动条
3) 菜单层级重构:一级只留默认动作(宿主默认应用 / 打开方式 ▸ / 在文件管理器中显示 /
新 Tab / 在侧边打开 / 下载 / 压缩并下载 / 复制路径 / 重命名 / 删除;目录行另有
新建文件夹、上传到此处),应用列表整体收进「打开方式」子菜单;宿主探测到本机关联
应用时默认不显示插件自研 open-with 项目,新增设置开关 openWithPluginTargets
(editor descriptor 的 pluginToggles)可强制并存;宿主不可用时插件项目照旧
4) 压缩并下载显示进度:宿主改为可观测任务(archive.build / archive.status /
/sidebar/archive?id=,4 并发 + 5 分钟 TTL + sessionId 绑定,buildZip 逐条目
onProgress),客户端轮询 250ms 显示「正在打包 done/total · N%」,ready 后 blob 下载,
失败进 zipFailed
5) 修复「在侧边打开没有效果」:根因是它只写插件底部工作台(原生右侧栏的 tab 不在
bottomSplits 里),现按承载面分流 —— 原生 tab 走 service.openTab(target:'side') →
宿主 openResource(preferNewPane, revealIfOpened:false) 真正分栏;底部工作台行为不变
(接入 API 变更已同步 docs/external-plugin-guide.md)
验证:typecheck / lint / test(135 文件 1461 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
新增/更新测试:menu-perf(3)、archive(10)、open-in-app(18)、open-with(10)、multiselect(9)、
zip(37)、service(64)、editor-host(11)、builtins(23)、locales(20)
This commit is contained in:
+201
-1
@@ -10,10 +10,11 @@
|
||||
* `ensureWorkspacePath` first), and a symlink is skipped rather than followed,
|
||||
* so it can neither escape the workspace nor cycle.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { SidebarError } from './wire.ts'
|
||||
import { ZIP_MAX_ENTRIES, type ZipEntry } from './zip.ts'
|
||||
import { buildZip, ZIP_MAX_ENTRIES, type ZipEntry } from './zip.ts'
|
||||
|
||||
/** Maximum archive-name length (keeps the Content-Disposition header sane). */
|
||||
export const ARCHIVE_NAME_MAX = 120
|
||||
@@ -183,3 +184,202 @@ export async function collectZipEntries(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How many archives one host may build at the same time. */
|
||||
export const ARCHIVE_MAX_BUILDING = 4
|
||||
/** How long a finished (or failed) archive stays downloadable. */
|
||||
export const ARCHIVE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
/** One archive build: the state the client polls, plus the finished bytes. */
|
||||
interface ArchiveTask {
|
||||
id: string
|
||||
sessionId: string
|
||||
name: string
|
||||
state: 'building' | 'ready' | 'error'
|
||||
done: number
|
||||
total: number
|
||||
bytes: number
|
||||
error?: string
|
||||
zip?: Buffer
|
||||
/** When the task stops being downloadable (set when it settles). */
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
/** The status shape the client reads (never carries the bytes). */
|
||||
export interface ArchiveStatus {
|
||||
state: 'building' | 'ready' | 'error'
|
||||
done: number
|
||||
total: number
|
||||
bytes: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Why a download/status read could not be served. */
|
||||
export type ArchiveLookup =
|
||||
| { ok: true; task: ArchiveTask }
|
||||
| { ok: false; reason: 'missing' | 'forbidden' | 'building' | 'expired' | 'error'; message: string }
|
||||
|
||||
/** A resolved download: the bytes plus the sanitized name they were built for. */
|
||||
export interface ArchiveDownload {
|
||||
name: string
|
||||
zip: Buffer
|
||||
}
|
||||
|
||||
/** The response face {@link respondArchiveDownload} writes through. */
|
||||
export interface ArchiveDownloadResponse {
|
||||
writeHead(status: number, headers?: Record<string, string>): void
|
||||
end(chunk?: Buffer | string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer one `GET /sidebar/archive?id=&sessionId=` from the task table.
|
||||
*
|
||||
* Split out of index.ts because this mapping IS the contract: 400 without
|
||||
* both parameters, 403 for another session's task, 409 while it builds, 410
|
||||
* for a failed build, 404 for unknown/expired/already-downloaded, 200 with the
|
||||
* RFC 5987 disposition otherwise. Kept here so all six branches are testable
|
||||
* without a live cordis mount.
|
||||
* @param tasks - the plugin's task table.
|
||||
* @param query - the request's `sessionId` / `id` values (null = absent).
|
||||
* @param res - a minimal response face.
|
||||
* @throws {SidebarError} for every non-200 branch (callers turn it into the
|
||||
* JSON error envelope they already use).
|
||||
*/
|
||||
export function respondArchiveDownload(
|
||||
tasks: ArchiveTasks,
|
||||
query: { sessionId: string | null; id: string | null },
|
||||
res: ArchiveDownloadResponse,
|
||||
): void {
|
||||
if (query.sessionId === null || query.id === null || query.id === '') {
|
||||
throw new SidebarError('bad-request', 'sessionId and id are required')
|
||||
}
|
||||
const found = tasks.lookup(query.id, query.sessionId)
|
||||
if (!found.ok || found.task.state !== 'ready' || found.task.zip === undefined) {
|
||||
if (found.ok) throw new SidebarError('not-found', 'archive is gone', 404)
|
||||
if (found.reason === 'building') throw new SidebarError('bad-request', found.message, 409)
|
||||
if (found.reason === 'forbidden') throw new SidebarError('forbidden', found.message, 403)
|
||||
if (found.reason === 'error') throw new SidebarError('fs-error', found.message, 410)
|
||||
throw new SidebarError('not-found', found.message, 404)
|
||||
}
|
||||
const { name, zip } = found.task
|
||||
// A download consumes the task: the bytes are not kept for a second fetch
|
||||
// (the task table is a queue, not a store).
|
||||
tasks.release(found.task.id)
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/zip',
|
||||
// Never the raw name: a non-latin1 value (中文目录 → 报告.zip) makes Node's
|
||||
// writeHead throw and turns the download into a 500.
|
||||
'content-disposition': contentDispositionOf(name),
|
||||
'content-length': String(zip.byteLength),
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
res.end(zip)
|
||||
}
|
||||
|
||||
/**
|
||||
* The host-side archive task table.
|
||||
*
|
||||
* A selection is collected up front (cheap: names + sizes) and then zipped in
|
||||
* the BACKGROUND, so the client can show progress instead of staring at a
|
||||
* spinner: `start` returns an id immediately, `status` reports
|
||||
* `building → ready | error`, and `download` hands over the bytes exactly once.
|
||||
* The table is bounded twice — {@link ARCHIVE_MAX_BUILDING} concurrent builds
|
||||
* and {@link ARCHIVE_TTL_MS} since a finished one — and every read is
|
||||
* session-scoped, so one session can never fetch another's archive.
|
||||
*/
|
||||
export interface ArchiveTasks {
|
||||
/** Start a build; rejects with bad-request when the concurrency cap is hit. */
|
||||
start(input: { sessionId: string; name: string; entries: readonly ZipEntry[] }): { id: string; entries: number }
|
||||
/** Poll one task (a settled task past its TTL reads as missing). */
|
||||
status(id: string, sessionId: string): ArchiveStatus
|
||||
/** Look one task up for a download; `sessionId` must match the builder's. */
|
||||
lookup(id: string, sessionId: string): ArchiveLookup
|
||||
/** Release a task (called after its bytes were served). */
|
||||
release(id: string): void
|
||||
/** Number of live tasks (tests / diagnostics). */
|
||||
size(): number
|
||||
}
|
||||
|
||||
/** Create one archive task table (one per plugin mount). */
|
||||
export function createArchiveTasks(): ArchiveTasks {
|
||||
const tasks = new Map<string, ArchiveTask>()
|
||||
|
||||
/** Drop settled tasks past their TTL (called on every access). */
|
||||
const sweep = (now: number): void => {
|
||||
for (const [id, task] of tasks) {
|
||||
if (task.state !== 'building' && task.expiresAt <= now) tasks.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** The task one (id, sessionId) pair names, or why it cannot be read. */
|
||||
const lookup = (id: string, sessionId: string): ArchiveLookup => {
|
||||
sweep(Date.now())
|
||||
const task = tasks.get(id)
|
||||
if (task === undefined) return { ok: false, reason: 'missing', message: 'unknown or expired archive' }
|
||||
if (task.sessionId !== sessionId) return { ok: false, reason: 'forbidden', message: 'archive belongs to another session' }
|
||||
if (task.state === 'building') return { ok: false, reason: 'building', message: 'archive is still being built' }
|
||||
if (task.state === 'error') return { ok: false, reason: 'error', message: task.error ?? 'archive failed' }
|
||||
return { ok: true, task }
|
||||
}
|
||||
|
||||
return {
|
||||
start({ sessionId, name, entries }) {
|
||||
sweep(Date.now())
|
||||
let building = 0
|
||||
for (const task of tasks.values()) if (task.state === 'building') building += 1
|
||||
if (building >= ARCHIVE_MAX_BUILDING) {
|
||||
throw new SidebarError('bad-request', `too many archives are being built (max ${ARCHIVE_MAX_BUILDING})`, 409)
|
||||
}
|
||||
const task: ArchiveTask = {
|
||||
id: `ar-${randomUUID()}`,
|
||||
sessionId,
|
||||
name,
|
||||
state: 'building',
|
||||
done: 0,
|
||||
total: entries.length,
|
||||
bytes: 0,
|
||||
expiresAt: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
tasks.set(task.id, task)
|
||||
// Detached on purpose: the POST answers as soon as the id exists, and the
|
||||
// task table (not the request) owns the error from here on.
|
||||
void buildZip([...entries], {
|
||||
onProgress: (progress) => {
|
||||
task.done = progress.done
|
||||
task.bytes = progress.bytes
|
||||
},
|
||||
}).then((zip) => {
|
||||
task.zip = zip
|
||||
task.state = 'ready'
|
||||
task.done = task.total
|
||||
task.expiresAt = Date.now() + ARCHIVE_TTL_MS
|
||||
}, (error: unknown) => {
|
||||
task.state = 'error'
|
||||
task.error = error instanceof Error ? error.message : String(error)
|
||||
task.expiresAt = Date.now() + ARCHIVE_TTL_MS
|
||||
})
|
||||
return { id: task.id, entries: entries.length }
|
||||
},
|
||||
status(id, sessionId) {
|
||||
sweep(Date.now())
|
||||
const task = tasks.get(id)
|
||||
if (task === undefined) throw new SidebarError('not-found', 'unknown or expired archive', 404)
|
||||
if (task.sessionId !== sessionId) throw new SidebarError('forbidden', 'archive belongs to another session', 403)
|
||||
return {
|
||||
state: task.state,
|
||||
done: task.done,
|
||||
total: task.total,
|
||||
bytes: task.bytes,
|
||||
...(task.error === undefined ? {} : { error: task.error }),
|
||||
}
|
||||
},
|
||||
lookup,
|
||||
release(id) {
|
||||
tasks.delete(id)
|
||||
},
|
||||
size() {
|
||||
sweep(Date.now())
|
||||
return tasks.size
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,9 @@ export function EditorHost(props: {
|
||||
// target list, a URL vocabulary or a spawn route — the host reports which
|
||||
// applications are actually installed for THIS path.
|
||||
const openInApp = useMemo(() => createOpenInApp(ctx), [ctx])
|
||||
// The plugin's own service (native-tab opens, "open to the side"): absent in
|
||||
// stripped-down hosts, where every flow degrades to the bottom workbench.
|
||||
const service = ctx.get('betterSidebar')
|
||||
// The file tree's "open with" configuration (pluginSettings['editor']): a
|
||||
// blob subscription, so a pin click or a settings-page edit re-renders the
|
||||
// menu immediately. The parsed config also drives which targets are shown
|
||||
@@ -155,6 +158,11 @@ export function EditorHost(props: {
|
||||
)
|
||||
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
|
||||
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
|
||||
// The declarative "always show the plugin's own targets" switch (the editor
|
||||
// card's pluginToggles row): a plain boolean on the SAME blob, so both the
|
||||
// settings page and the tree's menu see one value. Absent/false keeps the
|
||||
// host-first behavior (the tree decides what to hide).
|
||||
const openWithShowPluginTargets = editorBlob.openWithPluginTargets === true
|
||||
// A path-less tab shows the empty-state hint in merged mode — and in split
|
||||
// mode it is the standalone explorer (tree-only, see the render below). A
|
||||
// folder tab is a folder window in BOTH modes: the tree rooted at the
|
||||
@@ -185,8 +193,22 @@ export function EditorHost(props: {
|
||||
* The context menu's "open to the side": a fresh editor tab (uid id — the
|
||||
* `'editor:' + path` convention would clash with the id safety net on a
|
||||
* second side-open of the same file) in a rightward split of THIS pane.
|
||||
*
|
||||
* Native right-Sidebar tabs do NOT live in `bottomSplits`, so the bottom
|
||||
* branch would fall through to `firstLeaf` — a pane the user has not
|
||||
* expanded, i.e. "nothing happened". Those tabs instead ask the host for a
|
||||
* second pane through the service (`target: 'side'` → the host's
|
||||
* `preferNewPane`), which is the same gesture in the surface the user is
|
||||
* actually looking at.
|
||||
*/
|
||||
const openFileSide = (absolute: string): void => {
|
||||
// `store.tabOpen` answers from THIS session's own state map — the bottom
|
||||
// workbench's splits. A natively-hosted tab (right Sidebar) is absent
|
||||
// from them even while it is on screen.
|
||||
if (service !== undefined && !store.tabOpen(scope.sessionId, tab.id)) {
|
||||
service.openTab({ type: 'editor', path: absolute, target: 'side' }, scope)
|
||||
return
|
||||
}
|
||||
store.reduce((state) => {
|
||||
const pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits)
|
||||
const fresh: SidebarTab = {
|
||||
@@ -405,6 +427,7 @@ export function EditorHost(props: {
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
openInApp={openInApp}
|
||||
openWithShowPluginTargets={openWithShowPluginTargets}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithConfig.pinned}
|
||||
openWithSsh={openWithSshActive(openWithConfig)}
|
||||
@@ -532,6 +555,7 @@ export function EditorHost(props: {
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
openInApp={openInApp}
|
||||
openWithShowPluginTargets={openWithShowPluginTargets}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithConfig.pinned}
|
||||
openWithSsh={openWithSshActive(openWithConfig)}
|
||||
|
||||
+326
-193
@@ -49,7 +49,10 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
||||
import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
||||
import { api, archiveUrl, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
|
||||
import {
|
||||
api, archiveBuild, archiveDownloadUrl, archiveStatus, downloadUrl, isOutsideWorkspaceMessage,
|
||||
type FsEntry,
|
||||
} from './api.ts'
|
||||
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
|
||||
import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx'
|
||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||
@@ -62,11 +65,12 @@ import { t } from './locales.ts'
|
||||
import type { BetterSidebarService } from './service.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import {
|
||||
Chip, ConfirmDialog, IconButton, Notice, SectionHeader, StatusBadge, useGitStatus,
|
||||
type GitFileStatus, type GitTone, type StatusTone,
|
||||
Chip, ConfirmDialog, IconButton, Notice, StatusBadge, useGitStatus,
|
||||
type GitTone, type StatusTone,
|
||||
} from './ui/index.ts'
|
||||
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
|
||||
import { useDirectoryWatch } from './use-dir-watch.ts'
|
||||
import { usePolling } from './use-polling.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LevelData {
|
||||
@@ -119,6 +123,12 @@ function badgeToneOf(tone: GitTone): StatusTone {
|
||||
return tone === 'copied' ? 'added' : tone
|
||||
}
|
||||
|
||||
/** The archive progress line's percentage (`0/0` — an empty selection — is 0%). */
|
||||
function archivePercent(progress: { done: number; total: number }): number {
|
||||
if (progress.total <= 0) return 0
|
||||
return Math.min(100, Math.round((progress.done / progress.total) * 100))
|
||||
}
|
||||
|
||||
/**
|
||||
* The drop overlay's hero art: an arrow rising out of a notched tray
|
||||
* (upload zone — the same glyph family as the toolbar's upload icon) and a
|
||||
@@ -203,12 +213,20 @@ interface FileRowProps {
|
||||
/** A drag hovers this row's PARENT directory (upload target). */
|
||||
dropTarget: boolean
|
||||
copied: boolean
|
||||
git: GitFileStatus | undefined
|
||||
/**
|
||||
* The row's git state as PRIMITIVES, never the store's status object: the
|
||||
* shared store rebuilds its per-path objects on every poll, so passing the
|
||||
* object would re-render every changed row every 2.5s (`React.memo` compares
|
||||
* by reference) — exactly the "frequent refresh" the file list was reported
|
||||
* for. Two strings make the poll invisible to the rows.
|
||||
*/
|
||||
gitTone: GitTone | undefined
|
||||
gitLetter: string | undefined
|
||||
actions: RowActions
|
||||
}
|
||||
|
||||
const FileRow = memo(function FileRow(props: FileRowProps): ReactNode {
|
||||
const { entry, depth, iconsVersion, service, selected, revealed, dropTarget, copied, git, actions } = props
|
||||
const { entry, depth, iconsVersion, service, selected, revealed, dropTarget, copied, gitTone, gitLetter, actions } = props
|
||||
// Referenced so a registry change re-renders the row with its new icon.
|
||||
void iconsVersion
|
||||
const icon = service !== undefined ? service.fileIcon(entry.path, 14) : builtinFileIcon(entry.path, 14)
|
||||
@@ -239,11 +257,13 @@ const FileRow = memo(function FileRow(props: FileRowProps): ReactNode {
|
||||
onContextMenu={(event) => { actions.contextMenu(event, entry.path, false) }}
|
||||
>
|
||||
{icon}
|
||||
<span className={clsx(css.explorerName, git !== undefined && css.explorerGitName)} data-git-tone={git?.tone}>
|
||||
<span className={clsx(css.explorerName, gitTone !== undefined && css.explorerGitName)} data-git-tone={gitTone}>
|
||||
{entry.name}
|
||||
</span>
|
||||
{entry.isSymlink && <IconLinkOutlineRegular size={12} className={css.explorerSymlink} />}
|
||||
{git !== undefined && <StatusBadge tone={badgeToneOf(git.tone)} title={gitToneLabel(git.tone)}>{git.letter}</StatusBadge>}
|
||||
{gitTone !== undefined && gitLetter !== undefined && (
|
||||
<StatusBadge tone={badgeToneOf(gitTone)} title={gitToneLabel(gitTone)}>{gitLetter}</StatusBadge>
|
||||
)}
|
||||
{copied
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
: (
|
||||
@@ -332,6 +352,57 @@ const DirRow = memo(function DirRow(props: DirRowProps): ReactNode {
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* The workspace ROOT row (the session's own folder). Memoized like the other
|
||||
* rows: it has no name/selection churn, so a parent re-render (menu state, copy
|
||||
* flash, apps) must not re-resolve its glyph either.
|
||||
*/
|
||||
interface RootRowProps {
|
||||
path: string
|
||||
iconsVersion: number
|
||||
service: BetterSidebarService | undefined
|
||||
/** A drag hovers the workspace root (drop target). */
|
||||
dropTarget: boolean
|
||||
/** A change exists at or below the root (tinted). */
|
||||
gitChanged: boolean
|
||||
copied: boolean
|
||||
actions: RowActions
|
||||
}
|
||||
|
||||
const RootRow = memo(function RootRow(props: RootRowProps): ReactNode {
|
||||
const { path, iconsVersion, service, dropTarget, gitChanged, copied, actions } = props
|
||||
void iconsVersion
|
||||
const icon = service !== undefined ? service.folderIcon(path, true, 14) : builtinFolderIcon(true, 14)
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.explorerRow, dropTarget && css.explorerRowDropTarget)}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onDragOver={(event) => { actions.dragOver(event, path) }}
|
||||
onDrop={(event) => { actions.drop(event, path, true) }}
|
||||
onContextMenu={(event) => { actions.contextMenu(event, path, true) }}
|
||||
>
|
||||
{icon}
|
||||
<span className={clsx(css.explorerName, gitChanged && css.explorerDirChanged)}>{baseName(path)}</span>
|
||||
{copied
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
actions.reference(path, true)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export function FileTree(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
@@ -390,6 +461,15 @@ export function FileTree(props: {
|
||||
* nobody can see (the workbench keeps every tab body mounted).
|
||||
*/
|
||||
visible?: boolean
|
||||
/**
|
||||
* Keep the plugin's own open-with targets in the "打开方式" submenu even when
|
||||
* the host reports local applications for the path. Default (false): the
|
||||
* host's own list wins, and the plugin's fixed targets (file manager /
|
||||
* VS Code / Cursor / Zed / custom editors) show only when the host cannot
|
||||
* offer any — so the menu is not two near-identical lists. The caller reads
|
||||
* the `openWithPluginTargets` plugin setting and passes it down.
|
||||
*/
|
||||
openWithShowPluginTargets?: boolean
|
||||
/**
|
||||
* The sidebar registry service: when present, externally registered file
|
||||
* icons (`registerFileIcon`) outrank the host's file-type artwork on file rows.
|
||||
@@ -399,10 +479,12 @@ export function FileTree(props: {
|
||||
}) {
|
||||
// `onToggle` / `onOpenFile` are read through `propsRef` (the stable row
|
||||
// callbacks must not change identity when the caller re-renders).
|
||||
// `onReferenceFile` is read through `propsRef` like the other row callbacks.
|
||||
const {
|
||||
sessionId, cwd, store, expanded, revealed, onOpenFileNewTab, onOpenFileSide,
|
||||
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
|
||||
onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service,
|
||||
openWithShowPluginTargets,
|
||||
onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service,
|
||||
} = props
|
||||
/** The live props for the stable callbacks below (identity churns per render). */
|
||||
const propsRef = useRef(props)
|
||||
@@ -419,10 +501,8 @@ export function FileTree(props: {
|
||||
() => service?.subscribe(() => { setIconsVersion(version => version + 1) }),
|
||||
[service],
|
||||
)
|
||||
/**
|
||||
* One directory row's leading glyph (the root row and the new-folder
|
||||
* editor use it directly; FileRow/DirRow resolve their own).
|
||||
*/
|
||||
/** One directory's leading glyph for the surfaces WITHOUT a memoized row
|
||||
* (the inline rename and new-folder editors). */
|
||||
const dirRowIcon = (path: string, open: boolean): ReactNode =>
|
||||
service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14)
|
||||
const dataRef = useRef(data)
|
||||
@@ -812,7 +892,9 @@ export function FileTree(props: {
|
||||
kindRef.current.set(path, isDir)
|
||||
setSelection(new Set([path]))
|
||||
}
|
||||
setApps(null)
|
||||
// NOTE: the previous menu's app answer is deliberately KEPT — the render
|
||||
// only trusts it for the matching path, and dropping it here would cost an
|
||||
// extra render (and, on a reopen, a disabled "Loading…" frame).
|
||||
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
|
||||
}, [setSelection])
|
||||
|
||||
@@ -946,22 +1028,30 @@ export function FileTree(props: {
|
||||
}, [openInApp])
|
||||
|
||||
/**
|
||||
* The open menu's app rows are fetched while the menu is open: the host
|
||||
* resolves a file's registered applications (or a directory's application
|
||||
* catalogue). A host that cannot hand paths to a desktop keeps `appReady`
|
||||
* false, so no listing is attempted.
|
||||
* The host's application list, CACHED per path (the successful answers only;
|
||||
* a failed listing is retried on the next open). The list is a property of
|
||||
* the machine + the path, so reopening the same row's menu is instant: no
|
||||
* request, no disabled "Loading…" frame. A newly installed application shows
|
||||
* up on the next tree mount — the alternative (re-probing per open) is the
|
||||
* churn the menu was reported for.
|
||||
*/
|
||||
const appsCacheRef = useRef(new Map<string, readonly OpenInAppEntry[]>())
|
||||
useEffect(() => {
|
||||
if (rowMenu === null || openInApp === undefined || !appReady) {
|
||||
setApps(null)
|
||||
if (rowMenu === null || openInApp === undefined || !appReady) return
|
||||
const { path, isDir } = rowMenu
|
||||
const key = `${isDir ? 'd' : 'f'}\u0000${path}`
|
||||
const cached = appsCacheRef.current.get(key)
|
||||
if (cached !== undefined) {
|
||||
// Already answered for this path: publish it without a null frame (the
|
||||
// submenu never flashes its disabled "Loading…" row on a reopen).
|
||||
setApps({ path, entries: cached })
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const { path, isDir } = rowMenu
|
||||
setApps(null)
|
||||
const listing = isDir ? openInApp.directoryApps() : openInApp.fileApps(path)
|
||||
void listing.then((entries) => {
|
||||
if (cancelled) return
|
||||
if (entries !== null) appsCacheRef.current.set(key, entries)
|
||||
setApps({ path, entries })
|
||||
if (entries === null) reportOpenFailure(path)
|
||||
}).catch(() => {
|
||||
@@ -993,20 +1083,23 @@ export function FileTree(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* The PLUGIN half of the "open with" section: the pinned targets as DIRECT
|
||||
* rows, then the parent row with every target as a nested submenu (main's
|
||||
* shape — pins, chevron, SSH suffixes). The section only renders when the
|
||||
* caller wired the feature and at least one target is left.
|
||||
* The plugin's own open-with targets as SUBMENU rows: one row per target with
|
||||
* its brand mark, the SSH hint in remote mode, and the pin hot zone (main's
|
||||
* interaction). The built-in `explorer` target is dropped while the host is
|
||||
* ready — the host's own reveal row replaces it, and two identical "File
|
||||
* Manager" rows would be noise; without the host the target stays, so reveal
|
||||
* is never lost.
|
||||
*
|
||||
* `hostReady` filters out the built-in `explorer` target: the host's own
|
||||
* reveal row replaces it, and two identical "File Manager" rows would be
|
||||
* noise. When the host is NOT ready the target stays, so reveal is never
|
||||
* lost.
|
||||
* Visibility (the user's "有本机检测到的关联应用时就不显示固定的 openwith
|
||||
* 项目"): when the host reports applications for THIS path, the plugin rows
|
||||
* only appear if the caller turned `openWithShowPluginTargets` on (the
|
||||
* `openWithPluginTargets` setting). An unavailable host, or an empty/failed
|
||||
* listing, keeps them — that is what makes a remote (SSH) session usable.
|
||||
*/
|
||||
const openWithEntries = (hostReady: boolean): MenuEntry[] => {
|
||||
const pluginTargetRows = (hostHasApps: boolean): MenuItem[] => {
|
||||
if (openWithTargets === undefined || onOpenWith === undefined) return []
|
||||
const targets = hostReady ? openWithTargets.filter(target => target.id !== 'explorer') : openWithTargets
|
||||
if (targets.length === 0) return []
|
||||
if (hostHasApps && openWithShowPluginTargets !== true) return []
|
||||
const targets = appReady ? openWithTargets.filter(target => target.id !== 'explorer') : openWithTargets
|
||||
const pinnedIds = openWithPinned ?? []
|
||||
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
|
||||
* reveal gets the folder glyph, custom editors a generic code mark.
|
||||
@@ -1018,14 +1111,7 @@ export function FileTree(props: {
|
||||
if (target.id === 'zed') return <SiZedindustries size={14} />
|
||||
return <IconCodeOutlineRegular size={14} />
|
||||
}
|
||||
const pinned = targets
|
||||
.filter(target => pinnedIds.includes(target.id))
|
||||
.map<MenuItem>(target => ({
|
||||
id: `open-with:${target.id}`,
|
||||
label: openWithLabelOf(target),
|
||||
icon: itemIcon(target),
|
||||
}))
|
||||
const submenu = targets.map<MenuItem>(target => {
|
||||
return targets.map<MenuItem>(target => {
|
||||
const pinnedNow = pinnedIds.includes(target.id)
|
||||
return {
|
||||
id: `open-with:${target.id}`,
|
||||
@@ -1060,83 +1146,94 @@ export function FileTree(props: {
|
||||
icon: itemIcon(target),
|
||||
}
|
||||
})
|
||||
return [
|
||||
...pinned,
|
||||
...(pinned.length > 0 ? [{ id: 'open-with-sep', type: 'separator' } as MenuEntry] : []),
|
||||
{
|
||||
id: 'open-with-menu',
|
||||
// The primitives Menu renders no chevron for submenu parents — the
|
||||
// trailing arrow is supplied inside the label (full-width flex row,
|
||||
// right-aligned), matching how the submenu rows right-align the pin.
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{t('openWithMenu')}</span>
|
||||
<IconChevronRightOutlineRegular size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={14} />,
|
||||
submenu,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** The host's application list for one path (undefined while it is unknown). */
|
||||
const hostEntries = (target: { path: string; isDir: boolean }): readonly OpenInAppEntry[] | null | undefined =>
|
||||
apps !== null && apps.path === target.path ? apps.entries : undefined
|
||||
|
||||
/**
|
||||
* The submenu behind the "打开方式" row — this menu's ONE place for "open with
|
||||
* something else": the host's detected applications (the default handler is
|
||||
* hoisted to level 1 for files), then, per the visibility rule above, the
|
||||
* plugin's own fixed targets. A pending listing shows one disabled line; an
|
||||
* empty/failed listing the disabled `openInAppEmpty` line.
|
||||
*
|
||||
* Title: the frozen shape asked for a `t('openInApp')` label, but that key no
|
||||
* longer exists in the dictionaries (it was removed together with the
|
||||
* standalone section it used to head), and `openWithMenu` is the one
|
||||
* available synonym ("在应用中打开" / "Open with") — of the two near-duplicate
|
||||
* labels the task allowed keeping one, so this is it.
|
||||
*/
|
||||
const openWithSubmenu = (target: { path: string; isDir: boolean }): MenuItem => {
|
||||
const entries = hostEntries(target)
|
||||
const hostHasApps = entries !== undefined && entries !== null && entries.length > 0
|
||||
const rows: MenuItem[] = []
|
||||
if (appReady) {
|
||||
if (entries === undefined) rows.push({ id: 'open-in-app-loading', label: t('loading'), disabled: true })
|
||||
else if (entries === null || entries.length === 0) {
|
||||
rows.push({ id: 'open-in-app-empty', label: t('openInAppEmpty'), disabled: true })
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
// The default handler is the level-1 row for files; directories have
|
||||
// no default concept, so every catalogue app stays here.
|
||||
if (!target.isDir && entry.isDefault) continue
|
||||
rows.push({ id: `open-in-app:${entry.id}`, label: entry.name, icon: <AppGlyph entry={entry} /> })
|
||||
}
|
||||
}
|
||||
}
|
||||
// The primitive submenu takes MenuItem rows only (no separators), so the
|
||||
// host and plugin groups are told apart by their icons.
|
||||
rows.push(...pluginTargetRows(hostHasApps))
|
||||
return {
|
||||
id: 'open-with-menu',
|
||||
// The primitives Menu renders no chevron for submenu parents — the
|
||||
// trailing arrow is supplied inside the label (full-width flex row,
|
||||
// right-aligned), matching how the submenu rows right-align the pin.
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{t('openWithMenu')}</span>
|
||||
<IconChevronRightOutlineRegular size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={14} />,
|
||||
submenu: rows,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The HOST half of the section: the default-application row (files, when the
|
||||
* OS reports one) followed by every registered handler. A pending listing
|
||||
* shows one disabled line, an empty/failed listing the disabled
|
||||
* `openInAppEmpty` line — the reveal row below still renders either way.
|
||||
* The level-1 "default open" row for a file: the host's default handler, one
|
||||
* click away without opening a submenu. Absent while the listing is unknown,
|
||||
* when the host has no default, or for directories (no default concept).
|
||||
*/
|
||||
const hostAppEntries = (target: { path: string; isDir: boolean }): MenuEntry[] => {
|
||||
const entries = apps !== null && apps.path === target.path ? apps.entries : undefined
|
||||
if (entries === undefined) return [{ id: 'open-in-app-loading', label: t('loading'), disabled: true }]
|
||||
if (entries === null || entries.length === 0) {
|
||||
return [{ id: 'open-in-app-empty', label: t('openInAppEmpty'), disabled: true }]
|
||||
}
|
||||
const defaultAppRow = (target: { path: string; isDir: boolean }): MenuEntry | null => {
|
||||
if (!appReady || target.isDir) return null
|
||||
const entries = hostEntries(target)
|
||||
const fallback = entries?.find(entry => entry.isDefault)
|
||||
if (fallback === undefined) return null
|
||||
return { id: 'open-in-app:default', label: t('openInAppDefault'), icon: <AppGlyph entry={fallback} /> }
|
||||
}
|
||||
|
||||
/** The "打开方式" group of the ONE row menu: the host default, the submenu and
|
||||
* (host permitting) reveal. Empty when neither half can offer anything. */
|
||||
const openWithSection = (target: { path: string; isDir: boolean }): MenuEntry[] => {
|
||||
const fallback = defaultAppRow(target)
|
||||
const entries = hostEntries(target)
|
||||
const hostHasApps = entries !== undefined && entries !== null && entries.length > 0
|
||||
const submenu = appReady || pluginTargetRows(hostHasApps).length > 0 ? openWithSubmenu(target) : null
|
||||
if (fallback === null && submenu === null) return []
|
||||
const rows: MenuEntry[] = []
|
||||
const fallback = entries.find(entry => entry.isDefault)
|
||||
if (!target.isDir && fallback !== undefined) {
|
||||
rows.push({ id: 'open-in-app:default', label: t('openInAppDefault'), icon: <AppGlyph entry={fallback} /> })
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!target.isDir && entry.isDefault) continue
|
||||
rows.push({ id: `open-in-app:${entry.id}`, label: entry.name, icon: <AppGlyph entry={entry} /> })
|
||||
if (fallback !== null) rows.push(fallback)
|
||||
if (submenu !== null) rows.push(submenu)
|
||||
if (appReady) {
|
||||
rows.push(
|
||||
{ id: 'open-in-app-sep', type: 'separator' },
|
||||
{ id: 'reveal-in-file-manager', label: t('revealInFileManager'), icon: <IconFolderOpenRegular size={14} /> },
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* The menu's "打开方式" section, where the host's capability and the plugin's
|
||||
* own targets COEXIST (the user asked for both). No heading row: the host
|
||||
* group is self-describing (application names + "用默认应用打开"), and
|
||||
* `openInApp` / `openWithMenu` read as near-duplicates in every dictionary —
|
||||
* two synonyms stacked in one menu read as a repeated item. The frozen order:
|
||||
* host rows → separator → plugin rows (pinned direct + submenu) →
|
||||
* separator → host reveal.
|
||||
* Separators only appear between two non-empty groups. The host rows (and
|
||||
* reveal) vanish entirely when no handle was injected or the host cannot
|
||||
* hand paths to a desktop; the plugin rows then stand alone, which is what
|
||||
* keeps a remote/SSH session usable. The plugin's `explorer` target is
|
||||
* dropped only while the host is ready (see {@link openWithEntries}).
|
||||
*/
|
||||
const openWithSection = (target: { path: string; isDir: boolean }): MenuEntry[] => {
|
||||
const plugin = openWithEntries(appReady)
|
||||
if (!appReady && plugin.length === 0) return []
|
||||
const entries: MenuEntry[] = []
|
||||
const separate = (): void => {
|
||||
if (entries.length > 0) entries.push({ id: `open-with-group-${entries.length}`, type: 'separator' })
|
||||
}
|
||||
if (appReady) entries.push(...hostAppEntries(target))
|
||||
if (plugin.length > 0) {
|
||||
separate()
|
||||
entries.push(...plugin)
|
||||
}
|
||||
if (appReady) {
|
||||
separate()
|
||||
entries.push({ id: 'reveal-in-file-manager', label: t('revealInFileManager'), icon: <IconFolderOpenRegular size={14} /> })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection's ZIP row: two or more selected rows archive together, and a
|
||||
* LONE directory archives its own subtree. A lone FILE is skipped — it would
|
||||
@@ -1169,36 +1266,44 @@ export function FileTree(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Download one archive of `paths` through the host route. Unlike the
|
||||
* single-file download — a bare anchor whose failures belong to the browser
|
||||
* — the archive route answers the plugin's JSON error envelope for its caps
|
||||
* and refusals, and an anchor would happily save that envelope as a broken
|
||||
* `.zip`. So the bytes are fetched explicitly: a non-2xx answer is parsed for
|
||||
* its `{error: {message}}` envelope (the shape `api.ts` reads) and reported
|
||||
* through `zipFailed`; a 2xx body becomes an object URL handed to the same
|
||||
* hidden-anchor mechanics.
|
||||
* Zip the selection through the host's ASYNC archive route:
|
||||
*
|
||||
* The host builds the archive in one pass, which can take a while for a big
|
||||
* selection: `archiveBusy` drives the progress line under the error strip
|
||||
* while the request is in flight, and `archiveBusyRef` keeps a repeated pick
|
||||
* from packaging the same selection twice (the guard is silent by design —
|
||||
* the line it is already showing IS the feedback).
|
||||
* archiveBuild(scope, paths, name) → { id, entries }
|
||||
* → poll archiveStatus(id) every 250ms (self-scheduling: one request in
|
||||
* flight) while the job builds, driving the `zipProgress` line
|
||||
* → state 'ready' → fetch(archiveDownloadUrl(scope, id)) → blob → hidden
|
||||
* `<a download>` (the bytes are fetched explicitly because a bare anchor
|
||||
* would save the route's JSON error envelope as a broken .zip)
|
||||
* → any failure (build / status / download) → `zipFailed` in the strip.
|
||||
*
|
||||
* `archiveBusyRef` keeps a repeated pick from starting a second job (the
|
||||
* visible progress line is the feedback), and `archiveProgress` carries the
|
||||
* live `done/total` for the line and its percentage.
|
||||
*/
|
||||
const [archiveBusy, setArchiveBusy] = useState(false)
|
||||
const [archiveProgress, setArchiveProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const archiveBusyRef = useRef(false)
|
||||
const downloadArchive = (paths: readonly string[]): void => {
|
||||
if (archiveBusyRef.current) return
|
||||
const name = paths.length === 1 ? `${baseName(paths[0]!)}.zip` : 'archive.zip'
|
||||
let url: string
|
||||
try {
|
||||
url = archiveUrl({ sessionId, cwd }, paths, name)
|
||||
} catch (error: unknown) {
|
||||
setActionError(t('zipFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
return
|
||||
}
|
||||
archiveBusyRef.current = true
|
||||
setArchiveBusy(true)
|
||||
void fetch(url)
|
||||
/** The job being polled (state so the poller starts/stops with it). */
|
||||
const [archiveJobId, setArchiveJobId] = useState<string | null>(null)
|
||||
const archiveJobRef = useRef<{ id: string; name: string } | null>(null)
|
||||
|
||||
/** End the job: drop the progress line and release the guard. */
|
||||
const settleArchive = useCallback((): void => {
|
||||
archiveBusyRef.current = false
|
||||
archiveJobRef.current = null
|
||||
setArchiveJobId(null)
|
||||
setArchiveBusy(false)
|
||||
setArchiveProgress(null)
|
||||
}, [])
|
||||
|
||||
const failArchive = useCallback((message: string): void => {
|
||||
setActionError(t('zipFailed', { message }))
|
||||
settleArchive()
|
||||
}, [settleArchive])
|
||||
|
||||
/** Save the finished archive as `name` through a hidden anchor. */
|
||||
const saveArchive = useCallback((id: string, name: string): void => {
|
||||
void fetch(archiveDownloadUrl({ sessionId, cwd }, id))
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const envelope: { error?: { message?: string } } | null = await response.json().catch(() => null)
|
||||
@@ -1221,13 +1326,54 @@ export function FileTree(props: {
|
||||
// under them loses the file. One tick is enough and keeps the blob
|
||||
// from outliving the download.
|
||||
window.setTimeout(() => { URL.revokeObjectURL(objectUrl) }, 0)
|
||||
settleArchive()
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setActionError(t('zipFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
failArchive(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
.finally(() => {
|
||||
archiveBusyRef.current = false
|
||||
setArchiveBusy(false)
|
||||
}, [cwd, failArchive, sessionId, settleArchive])
|
||||
|
||||
/** The poller's callbacks, read through a ref so the polling TASK stays
|
||||
* identity-stable (a churned task restarts the loop every render). */
|
||||
const archiveHandlersRef = useRef({ fail: failArchive, save: saveArchive })
|
||||
archiveHandlersRef.current = { fail: failArchive, save: saveArchive }
|
||||
|
||||
usePolling(archiveJobId !== null, useCallback(async (signal: AbortSignal): Promise<void> => {
|
||||
const job = archiveJobRef.current
|
||||
if (job === null) return
|
||||
// The poller swallows a rejected task (it must keep the loop alive), so
|
||||
// every failure is caught HERE and turned into the strip's `zipFailed`.
|
||||
try {
|
||||
const status = await archiveStatus(job.id)
|
||||
// The transport may deliver a response after teardown; the signal is the
|
||||
// only reliable staleness guard (the route call takes no signal).
|
||||
if (signal.aborted) return
|
||||
setArchiveProgress({ done: status.done, total: status.total })
|
||||
if (status.state === 'error') {
|
||||
archiveHandlersRef.current.fail(status.error ?? `HTTP ${status.state}`)
|
||||
return
|
||||
}
|
||||
if (status.state === 'ready') archiveHandlersRef.current.save(job.id, job.name)
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) return
|
||||
archiveHandlersRef.current.fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}, []), { intervalMs: 250, mode: 'self-scheduling', immediate: true })
|
||||
|
||||
const downloadArchive = (paths: readonly string[]): void => {
|
||||
if (archiveBusyRef.current) return
|
||||
const name = paths.length === 1 ? `${baseName(paths[0]!)}.zip` : 'archive.zip'
|
||||
archiveBusyRef.current = true
|
||||
setArchiveBusy(true)
|
||||
setArchiveProgress(null)
|
||||
void archiveBuild({ sessionId, cwd }, paths, name)
|
||||
.then(({ id, entries }) => {
|
||||
archiveJobRef.current = { id, name }
|
||||
setArchiveProgress({ done: 0, total: entries })
|
||||
setArchiveJobId(id)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
failArchive(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1406,6 +1552,7 @@ export function FileTree(props: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const git = gitStatus.statusOf(entry.path)
|
||||
return (
|
||||
<FileRow
|
||||
key={entry.path}
|
||||
@@ -1416,7 +1563,8 @@ export function FileTree(props: {
|
||||
selected={selected.has(entry.path)}
|
||||
revealed={revealedSet.has(entry.path)}
|
||||
dropTarget={dropTarget === parentOf(entry.path)}
|
||||
git={gitStatus.statusOf(entry.path)}
|
||||
gitTone={git?.tone}
|
||||
gitLetter={git?.letter}
|
||||
copied={copiedPath === entry.path}
|
||||
actions={actions}
|
||||
/>
|
||||
@@ -1464,58 +1612,41 @@ export function FileTree(props: {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* The archive is built host-side in one pass: while it runs, the
|
||||
row sits right under the error strip (the two CAN coexist — a
|
||||
stale error stays readable while a new attempt is in flight).
|
||||
`role="status"` makes it a polite live region, so the busy state
|
||||
is announced without stealing focus. */}
|
||||
{/* The archive is built host-side, in one pass, while the STATUS
|
||||
poll drives `done/total`: the line sits right under the error
|
||||
strip (the two CAN coexist — a stale error stays readable while a
|
||||
new attempt is in flight). `role="status"` makes it a polite live
|
||||
region, so progress is announced without stealing focus. */}
|
||||
{archiveBusy && (
|
||||
<Notice kind="loading" tone="inline" role="status">
|
||||
{t('loading')}
|
||||
{archiveProgress === null
|
||||
? t('loading')
|
||||
: `${t('zipProgress', { done: archiveProgress.done, total: archiveProgress.total })} · ${archivePercent(archiveProgress)}%`}
|
||||
</Notice>
|
||||
)}
|
||||
{selected.size > 0 && (
|
||||
<SectionHeader
|
||||
className={css.explorerSelectionBar}
|
||||
label={t('filesSelected', { count: selected.size })}
|
||||
action={(
|
||||
<span className={css.explorerSelectionActions}>
|
||||
<Chip onClick={copySelectedPaths}>{t('copyPaths')}</Chip>
|
||||
<Chip onClick={() => { setConfirmDeleteSelected(true) }}>{t('deleteSelected')}</Chip>
|
||||
<Chip onClick={clearSelection}>{t('clearSelection')}</Chip>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onDragOver={(event) => { handleRowDragOver(event, root) }}
|
||||
onDrop={(event) => { handleDirDrop(event, root) }}
|
||||
onContextMenu={(event) => { handleContextMenu(event, root, true) }}
|
||||
>
|
||||
{dirRowIcon(root, true)}
|
||||
<span className={clsx(css.explorerName, gitStatus.dirHasChanges(root) && css.explorerDirChanged)}>
|
||||
{baseName(root)}
|
||||
</span>
|
||||
{copiedPath === root
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(root, true)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<RootRow
|
||||
path={root}
|
||||
iconsVersion={iconsVersion}
|
||||
service={service}
|
||||
dropTarget={dropTarget === root}
|
||||
gitChanged={gitStatus.dirHasChanges(root)}
|
||||
copied={copiedPath === root}
|
||||
actions={actions}
|
||||
/>
|
||||
{renderLevel(root, 1)}
|
||||
{/* The selection bar closes the scroll container (sticky bottom): it
|
||||
sits BELOW every row, never over them, and its appearance never
|
||||
shifts the tree the way a top bar did. */}
|
||||
{selected.size > 0 && (
|
||||
<div className={css.explorerSelectionBar} role="toolbar" aria-label={t('filesSelected', { count: selected.size })}>
|
||||
<span className={css.explorerSelectionText}>{t('filesSelected', { count: selected.size })}</span>
|
||||
<span className={css.explorerSelectionActions}>
|
||||
<Chip onClick={copySelectedPaths}>{t('copyPaths')}</Chip>
|
||||
<Chip onClick={() => { setConfirmDeleteSelected(true) }}>{t('deleteSelected')}</Chip>
|
||||
<Chip onClick={clearSelection}>{t('clearSelection')}</Chip>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{dropOver && dropRect !== null && createPortal(
|
||||
@@ -1586,32 +1717,34 @@ export function FileTree(props: {
|
||||
open={rowMenu !== null}
|
||||
onClose={() => { setRowMenu(null) }}
|
||||
items={[
|
||||
// The open escapes head the FILE menu (dirs only get copy).
|
||||
// 1-3: the "打开方式" group heads the menu — the host's default
|
||||
// handler, the one submenu holding every application (host + the
|
||||
// plugin's own, per the visibility rule) and the host's reveal.
|
||||
...(rowMenu === null ? [] : openWithSection(rowMenu)),
|
||||
// 4: the explicit open escapes (files only).
|
||||
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutlineRegular size={14} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpenRegular size={14} /> }]
|
||||
: []),
|
||||
...(rowMenu === null ? [] : openWithSection(rowMenu)),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutlineRegular size={14} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
// Directory actions: upload into, and create inside (the workspace
|
||||
// root row included).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
: []),
|
||||
// New folder into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'new-folder', label: t('newFolder'), icon: <IconPlusOutlineRegular size={14} /> }]
|
||||
: []),
|
||||
// ZIP of the current selection (≥2 rows, or one lone directory).
|
||||
// 5: ZIP of the current selection (≥2 rows, or one lone directory).
|
||||
...(rowMenu === null ? [] : zipEntries(rowMenu)),
|
||||
// 6: copy, then the mutations (never on the workspace root row).
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutlineRegular size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutlineRegular size={14} /> },
|
||||
// Explorer mutations close the menu; the workspace ROOT row is the
|
||||
// session itself — never renamable or deletable (server double-guards).
|
||||
...(rowMenu !== null && rowMenu.path !== cwd
|
||||
? [
|
||||
{ id: 'mutate-sep', type: 'separator' } as MenuEntry,
|
||||
|
||||
@@ -71,6 +71,10 @@ export function TreePanel(props: {
|
||||
openWithSsh?: boolean
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
/** Show the plugin's own open-with targets even when the host lists local
|
||||
* applications for the path (the `openWithPluginTargets` setting; passed
|
||||
* through to FileTree). */
|
||||
openWithShowPluginTargets?: boolean
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
/** A tree rename landed (passed through to FileTree for tab retargeting). */
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
@@ -88,6 +92,7 @@ export function TreePanel(props: {
|
||||
const {
|
||||
sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide,
|
||||
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
|
||||
openWithShowPluginTargets,
|
||||
onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service,
|
||||
} = props
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -295,6 +300,7 @@ export function TreePanel(props: {
|
||||
openWithSsh={openWithSsh}
|
||||
onOpenWith={onOpenWith}
|
||||
onToggleOpenWithPin={onToggleOpenWithPin}
|
||||
openWithShowPluginTargets={openWithShowPluginTargets}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
|
||||
+37
-8
@@ -440,15 +440,44 @@ export function downloadUrl(scope: SessionScope, path: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL of the archive route: one ZIP of every `paths` entry (files
|
||||
* and directories, recursive) served as an attachment. Same GET shape as
|
||||
* {@link downloadUrl}; the repeated `path` parameter is the only difference.
|
||||
* Start one archive build. The host fences and walks the selection, then
|
||||
* returns an id IMMEDIATELY — the zip itself is produced in the background and
|
||||
* polled through {@link api.archiveStatus}. `paths` are absolute paths in the
|
||||
* session's namespace (the same values `fsTree` / `downloadUrl` take).
|
||||
*/
|
||||
export function archiveUrl(scope: SessionScope, paths: readonly string[], name: string): string {
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, name })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
for (const path of paths) params.append('path', path)
|
||||
return `/sidebar/archive?${params.toString()}`
|
||||
export function archiveBuild(
|
||||
scope: SessionScope,
|
||||
paths: readonly string[],
|
||||
name: string,
|
||||
): Promise<{ id: string; entries: number }> {
|
||||
return call<{ id: string; entries: number }>('archive.build', scopePayload(scope, { paths: [...paths], name }))
|
||||
}
|
||||
|
||||
/** One archive build's progress (the shape `archive.status` returns). */
|
||||
export interface ArchiveBuildStatus {
|
||||
state: 'building' | 'ready' | 'error'
|
||||
/** Entries finished so far. */
|
||||
done: number
|
||||
/** Entries the archive will contain. */
|
||||
total: number
|
||||
/** Uncompressed bytes read so far. */
|
||||
bytes: number
|
||||
/** Present only when `state === 'error'`. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Poll one archive build (its id came from {@link archiveBuild}). */
|
||||
export function archiveStatus(id: string): Promise<ArchiveBuildStatus> {
|
||||
return call<ArchiveBuildStatus>('archive.status', { id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL of one FINISHED archive's bytes (the `archive.status` result
|
||||
* must be `ready` first): GET /sidebar/archive?id=…&sessionId=… The session
|
||||
* scope rides along because the host answers only the session that built it.
|
||||
*/
|
||||
export function archiveDownloadUrl(scope: SessionScope, id: string): string {
|
||||
return `/sidebar/archive?${new URLSearchParams({ sessionId: scope.sessionId, id }).toString()}`
|
||||
}
|
||||
|
||||
/** Shared URL builder for the /sidebar/file route (media vs download). */
|
||||
|
||||
@@ -73,6 +73,14 @@ export function builtinTabs(): readonly TabDescriptor[] {
|
||||
title: () => t('settingsFenceTitle'),
|
||||
desc: () => t('settingsFenceDesc'),
|
||||
}],
|
||||
// Plugin-owned rows (values live in `pluginSettings['editor']`): the
|
||||
// plugin's own open-with targets are shown only when the host reports
|
||||
// no local application, unless the user asks for both side by side.
|
||||
pluginToggles: [{
|
||||
key: 'openWithPluginTargets',
|
||||
title: () => t('settingsOpenWithPluginTitle'),
|
||||
desc: () => t('settingsOpenWithPluginDesc'),
|
||||
}],
|
||||
render: ({ pluginSettings, updatePluginSetting }) => (
|
||||
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
|
||||
),
|
||||
|
||||
@@ -58,6 +58,9 @@ export const ar: Record<string, string> = {
|
||||
editorEmptyHint: 'اختر ملفاً من لوحة الشجرة أو من حقل المسار بالأعلى لبدء المعاينة',
|
||||
openFileNewTab: 'فتح في تبويب جديد',
|
||||
openFileSide: 'فتح إلى الجانب',
|
||||
zipProgress: 'جارٍ الضغط {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'إظهار خيارات الفتح الخاصة بالإضافة دائماً',
|
||||
settingsOpenWithPluginDesc: 'افتراضياً تُعرض خيارات الفتح الخاصة بالإضافة فقط عندما لا يكتشف المضيف أي تطبيق مرتبط محلياً (مدير الملفات / VS Code / Cursor / Zed / محرّرات مخصّصة، SSH عن بُعد)؛ وبعد التفعيل تتواجد تطبيقات المضيف وأهداف الإضافة معاً',
|
||||
zipDownload: 'ضغط وتنزيل',
|
||||
zipDownloadCount: 'ضغط وتنزيل ({count} عناصر)',
|
||||
zipFailed: 'فشل إنشاء الأرشيف: {message}',
|
||||
|
||||
@@ -43,6 +43,9 @@ export const de: Record<string, string> = {
|
||||
editorEmptyHint: 'Wählen Sie eine Datei aus dem Dateibaum rechts oder dem Pfadeingabefeld oben, um mit der Vorschau zu beginnen',
|
||||
openFileNewTab: 'In neuem Tab öffnen',
|
||||
openFileSide: 'Seitlich öffnen',
|
||||
zipProgress: 'Archivierung läuft {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Plugin-eigene „Öffnen mit“-Ziele immer anzeigen',
|
||||
settingsOpenWithPluginDesc: 'Standardmäßig werden die plugin-eigenen „Öffnen mit“-Ziele nur gezeigt, wenn der Host keine lokal verknüpfte Anwendung erkennt (Dateimanager / VS Code / Cursor / Zed / benutzerdefinierte Editoren, SSH-Remote); nach dem Aktivieren stehen Host-Apps und Plugin-Ziele nebeneinander',
|
||||
zipDownload: 'Zippen und herunterladen',
|
||||
zipDownloadCount: 'Zippen und herunterladen ({count} Elemente)',
|
||||
zipFailed: 'Archivierung fehlgeschlagen: {message}',
|
||||
|
||||
@@ -50,6 +50,9 @@ export const fr: Record<string, string> = {
|
||||
editorEmptyHint: 'Choisissez un fichier dans l’arborescence à droite ou dans le champ de chemin ci-dessus pour commencer l’aperçu',
|
||||
openFileNewTab: 'Ouvrir dans un nouvel onglet',
|
||||
openFileSide: 'Ouvrir de côté',
|
||||
zipProgress: 'Archivage {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Toujours afficher les cibles « Ouvrir avec » du plugin',
|
||||
settingsOpenWithPluginDesc: 'Par défaut, les cibles « Ouvrir avec » du plugin ne s’affichent que si l’hôte ne détecte aucune application associée localement (Explorateur de fichiers / VS Code / Cursor / Zed / éditeurs personnalisés, SSH distant) ; une fois activé, les applications de l’hôte et les cibles du plugin coexistent',
|
||||
zipDownload: 'Compresser et télécharger',
|
||||
zipDownloadCount: 'Compresser et télécharger ({count} éléments)',
|
||||
zipFailed: 'Échec de l’archivage : {message}',
|
||||
|
||||
@@ -57,6 +57,9 @@ export const hi: Record<string, string> = {
|
||||
editorEmptyHint: 'पूर्वावलोकन शुरू करने के लिए दाएँ फ़ाइल ट्री या ऊपर पथ इनपुट से फ़ाइल चुनें',
|
||||
openFileNewTab: 'नए टैब में खोलें',
|
||||
openFileSide: 'बाजू में खोलें',
|
||||
zipProgress: 'संग्रह बनाया जा रहा है {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'प्लगइन के अपने खोलने के विकल्प हमेशा दिखाएँ',
|
||||
settingsOpenWithPluginDesc: 'डिफ़ॉल्ट रूप से प्लगइन के अपने खोलने के विकल्प तभी दिखते हैं जब होस्ट को कोई स्थानीय संबद्ध ऐप न मिले (फ़ाइल मैनेजर / VS Code / Cursor / Zed / कस्टम एडिटर, SSH रिमोट); चालू करने पर होस्ट ऐप और प्लगइन लक्ष्य साथ-साथ रहते हैं',
|
||||
zipDownload: 'ज़िप कर डाउनलोड करें',
|
||||
zipDownloadCount: 'ज़िप कर डाउनलोड करें ({count} आइटम)',
|
||||
zipFailed: 'संग्रह बनाना विफल: {message}',
|
||||
|
||||
@@ -55,6 +55,9 @@ export const id: Record<string, string> = {
|
||||
editorEmptyHint: 'Pilih berkas dari panel pohon atau input path di atas untuk mulai pratinjau',
|
||||
openFileNewTab: 'Buka di Tab Baru',
|
||||
openFileSide: 'Buka di Samping',
|
||||
zipProgress: 'Mengarsipkan {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Selalu tampilkan opsi buka milik plugin',
|
||||
settingsOpenWithPluginDesc: 'Secara default opsi buka milik plugin hanya muncul saat host tidak mendeteksi aplikasi terkait di perangkat ini (File Manager / VS Code / Cursor / Zed / editor kustom, SSH jarak jauh); setelah diaktifkan, aplikasi host dan target plugin tampil berdampingan',
|
||||
zipDownload: 'Zip dan unduh',
|
||||
zipDownloadCount: 'Zip dan unduh ({count} item)',
|
||||
zipFailed: 'Gagal membuat arsip: {message}',
|
||||
|
||||
@@ -48,6 +48,9 @@ export const it: Record<string, string> = {
|
||||
editorEmptyHint: 'Scelga un file dal pannello dell’albero a destra o dal campo del percorso in alto per iniziare l’anteprima',
|
||||
openFileNewTab: 'Apri in una nuova scheda',
|
||||
openFileSide: 'Apri a lato',
|
||||
zipProgress: 'Archiviazione {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Mostra sempre le destinazioni «Apri con» del plugin',
|
||||
settingsOpenWithPluginDesc: 'Per impostazione predefinita le destinazioni «Apri con» del plugin compaiono solo quando l’host non rileva applicazioni associate in locale (Esplora risorse / VS Code / Cursor / Zed / editor personalizzati, SSH remoto); una volta attivata, le app dell’host e le destinazioni del plugin convivono',
|
||||
zipDownload: 'Comprimi e scarica',
|
||||
zipDownloadCount: 'Comprimi e scarica ({count} elementi)',
|
||||
zipFailed: 'Creazione archivio non riuscita: {message}',
|
||||
|
||||
@@ -57,6 +57,9 @@ export const ja: Record<string, string> = {
|
||||
editorEmptyHint: '右のファイルツリーまたは上のパス入力欄からファイルを選んでプレビュー',
|
||||
openFileNewTab: '新しいタブで開く',
|
||||
openFileSide: '横に開く',
|
||||
zipProgress: '圧縮中 {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'プラグイン独自の「アプリで開く」を常に表示',
|
||||
settingsOpenWithPluginDesc: '既定では、ホストがローカルの関連アプリ(エクスプローラー / VS Code / Cursor / Zed / カスタムエディター、SSH リモート)を検出できない場合にのみ、プラグイン独自の「アプリで開く」を表示します。オンにするとホストのアプリとプラグインのターゲットが併存します',
|
||||
zipDownload: '圧縮してダウンロード',
|
||||
zipDownloadCount: '圧縮してダウンロード({count} 件)',
|
||||
zipFailed: 'アーカイブの作成に失敗しました:{message}',
|
||||
|
||||
@@ -49,6 +49,9 @@ export const ko: Record<string, string> = {
|
||||
editorEmptyHint: '오른쪽 파일 트리나 위쪽 경로 입력란에서 파일을 선택해 미리보기를 시작하세요',
|
||||
openFileNewTab: '새 Tab으로 열기',
|
||||
openFileSide: '측면에 열기',
|
||||
zipProgress: '압축 중 {done}/{total}',
|
||||
settingsOpenWithPluginTitle: '플러그인 자체 "앱에서 열기" 항상 표시',
|
||||
settingsOpenWithPluginDesc: '기본적으로 플러그인 자체의 "앱에서 열기"는 호스트가 로컬 연결 앱(파일 탐색기 / VS Code / Cursor / Zed / 사용자 지정 편집기, SSH 원격)을 감지하지 못할 때만 표시됩니다. 켜면 호스트 앱과 플러그인 대상이 함께 표시됩니다',
|
||||
zipDownload: '압축하여 다운로드',
|
||||
zipDownloadCount: '압축하여 다운로드({count}개 항목)',
|
||||
zipFailed: '압축 파일 생성 실패: {message}',
|
||||
|
||||
@@ -55,6 +55,9 @@ export const nl: Record<string, string> = {
|
||||
editorEmptyHint: 'Kies een bestand uit het boompaneel of het pad-invoerveld hierboven om te beginnen met previewen',
|
||||
openFileNewTab: 'Openen in nieuw tabblad',
|
||||
openFileSide: 'Openen aan de zijkant',
|
||||
zipProgress: 'Archiveren {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Eigen "Openen met"-doelen van de plugin altijd tonen',
|
||||
settingsOpenWithPluginDesc: 'Standaard worden de eigen "Openen met"-doelen van de plugin alleen getoond als de host geen lokaal gekoppelde app detecteert (Bestandsbeheerder / VS Code / Cursor / Zed / aangepaste editors, SSH op afstand); na inschakelen bestaan host-apps en plugin-doelen naast elkaar',
|
||||
zipDownload: 'Zippen en downloaden',
|
||||
zipDownloadCount: 'Zippen en downloaden ({count} items)',
|
||||
zipFailed: 'Archiveren mislukt: {message}',
|
||||
|
||||
@@ -59,6 +59,9 @@ export const pl: Record<string, string> = {
|
||||
editorEmptyHint: 'Wybierz plik z panelu drzewa lub pola ścieżki powyżej, aby rozpocząć podgląd',
|
||||
openFileNewTab: 'Otwórz w nowej karcie',
|
||||
openFileSide: 'Otwórz z boku',
|
||||
zipProgress: 'Archiwizowanie {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Zawsze pokazuj własne opcje „Otwórz za pomocą” wtyczki',
|
||||
settingsOpenWithPluginDesc: 'Domyślnie własne opcje „Otwórz za pomocą” wtyczki są pokazywane tylko wtedy, gdy host nie wykryje lokalnie skojarzonej aplikacji (Menedżer plików / VS Code / Cursor / Zed / niestandardowe edytory, SSH zdalny); po włączeniu aplikacje hosta i cele wtyczki współistnieją',
|
||||
zipDownload: 'Spakuj i pobierz',
|
||||
zipDownloadCount: 'Spakuj i pobierz ({count} elementów)',
|
||||
zipFailed: 'Tworzenie archiwum nie powiodło się: {message}',
|
||||
|
||||
@@ -40,6 +40,9 @@ export const pt: Record<string, string> = {
|
||||
editorEmptyHint: 'Escolha um arquivo na árvore do painel ou no campo de caminho acima para começar a pré-visualizar',
|
||||
openFileNewTab: 'Abrir em nova aba',
|
||||
openFileSide: 'Abrir ao lado',
|
||||
zipProgress: 'Compactando {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Mostrar sempre as opções "Abrir com" do plugin',
|
||||
settingsOpenWithPluginDesc: 'Por padrão, as opções "Abrir com" do plugin só aparecem quando o host não detecta nenhum aplicativo associado localmente (Gerenciador de arquivos / VS Code / Cursor / Zed / editores personalizados, SSH remoto); ao ativar, os aplicativos do host e os destinos do plugin coexistem',
|
||||
zipDownload: 'Compactar e baixar',
|
||||
zipDownloadCount: 'Compactar e baixar ({count} itens)',
|
||||
zipFailed: 'Falha ao compactar: {message}',
|
||||
|
||||
@@ -53,6 +53,9 @@ export const ru: Record<string, string> = {
|
||||
editorEmptyHint: 'Выберите файл из дерева справа или введите путь выше, чтобы начать предпросмотр',
|
||||
openFileNewTab: 'Открыть в новой вкладке',
|
||||
openFileSide: 'Открыть сбоку',
|
||||
zipProgress: 'Архивация {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Всегда показывать собственные варианты «Открыть в приложении» плагина',
|
||||
settingsOpenWithPluginDesc: 'По умолчанию собственные варианты «Открыть в приложении» плагина показываются только когда хост не обнаружил локально связанных приложений (Проводник / VS Code / Cursor / Zed / собственные редакторы, SSH-удалённо); после включения приложения хоста и цели плагина сосуществуют',
|
||||
zipDownload: 'Сжать в архив и скачать',
|
||||
zipDownloadCount: 'Сжать в архив и скачать ({count} элементов)',
|
||||
zipFailed: 'Сбой создания архива: {message}',
|
||||
|
||||
@@ -40,6 +40,9 @@ export const sv: Record<string, string> = {
|
||||
editorEmptyHint: 'Välj en fil från trädpanelen eller sökvägsinmatningen ovan för att börja förhandsgranska',
|
||||
openFileNewTab: 'Öppna i ny flik',
|
||||
openFileSide: 'Öppna vid sidan',
|
||||
zipProgress: 'Arkiverar {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Visa alltid pluginets egna "Öppna med"-mål',
|
||||
settingsOpenWithPluginDesc: 'Som standard visas pluginets egna "Öppna med"-mål bara när värden inte hittar någon lokalt associerad app (Filhanterare / VS Code / Cursor / Zed / anpassade editorer, SSH på distans); när det slås på finns värdens appar och pluginets mål sida vid sida',
|
||||
zipDownload: 'Zippa och ladda ner',
|
||||
zipDownloadCount: 'Zippa och ladda ner ({count} objekt)',
|
||||
zipFailed: 'Kunde inte skapa arkivet: {message}',
|
||||
|
||||
@@ -57,6 +57,9 @@ export const th: Record<string, string> = {
|
||||
editorEmptyHint: 'เลือกไฟล์จากพาเนลต้นไม้ไฟล์หรือช่องกรอกพาธด้านบนเพื่อเริ่มพรีวิว',
|
||||
openFileNewTab: 'เปิดใน Tab ใหม่',
|
||||
openFileSide: 'เปิดด้านข้าง',
|
||||
zipProgress: 'กำลังบีบอัด {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'แสดงตัวเลือก "เปิดด้วย" ของปลั๊กอินเสมอ',
|
||||
settingsOpenWithPluginDesc: 'ตามค่าเริ่มต้น ตัวเลือก "เปิดด้วย" ของปลั๊กอินจะแสดงเฉพาะเมื่อโฮสต์ตรวจไม่พบแอปที่เชื่อมโยงในเครื่อง (ตัวจัดการไฟล์ / VS Code / Cursor / Zed / ตัวแก้ไขที่กำหนดเอง, SSH ระยะไกล); เมื่อเปิดใช้ แอปของโฮสต์และเป้าหมายของปลั๊กอินจะอยู่ร่วมกัน',
|
||||
zipDownload: 'บีบอัดและดาวน์โหลด',
|
||||
zipDownloadCount: 'บีบอัดและดาวน์โหลด ({count} รายการ)',
|
||||
zipFailed: 'สร้างไฟล์บีบอัดไม่สำเร็จ: {message}',
|
||||
|
||||
@@ -57,6 +57,9 @@ export const tr: Record<string, string> = {
|
||||
editorEmptyHint: 'Önizlemeye başlamak için sağdaki dosya ağacından veya yukarıdaki yol girişinden bir dosya seçin',
|
||||
openFileNewTab: 'Yeni sekmede aç',
|
||||
openFileSide: 'Yana aç',
|
||||
zipProgress: 'Arşivleniyor {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Eklentinin kendi "Birlikte aç" seçeneklerini her zaman göster',
|
||||
settingsOpenWithPluginDesc: 'Varsayılan olarak eklentinin kendi "Birlikte aç" seçenekleri yalnızca ana makine yerel bir ilişkili uygulama algılamadığında gösterilir (Dosya yöneticisi / VS Code / Cursor / Zed / özel düzenleyiciler, uzak SSH); açıldığında ana makine uygulamaları ile eklenti hedefleri bir arada bulunur',
|
||||
zipDownload: 'Sıkıştır ve indir',
|
||||
zipDownloadCount: 'Sıkıştır ve indir ({count} öğe)',
|
||||
zipFailed: 'Arşiv oluşturulamadı: {message}',
|
||||
|
||||
@@ -57,6 +57,9 @@ export const vi: Record<string, string> = {
|
||||
editorEmptyHint: 'Chọn tệp từ cây tệp bên phải hoặc ô nhập đường dẫn phía trên để xem trước',
|
||||
openFileNewTab: 'Mở trong Tab mới',
|
||||
openFileSide: 'Mở ở bên cạnh',
|
||||
zipProgress: 'Đang nén {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Luôn hiển thị tùy chọn "Mở bằng" của plugin',
|
||||
settingsOpenWithPluginDesc: 'Mặc định, tùy chọn "Mở bằng" của plugin chỉ hiển thị khi máy chủ không phát hiện ứng dụng liên kết cục bộ (Trình quản lý tệp / VS Code / Cursor / Zed / trình soạn thảo tùy chỉnh, SSH từ xa); khi bật, ứng dụng của máy chủ và mục tiêu của plugin cùng tồn tại',
|
||||
zipDownload: 'Nén và tải xuống',
|
||||
zipDownloadCount: 'Nén và tải xuống ({count} mục)',
|
||||
zipFailed: 'Tạo tệp nén thất bại: {message}',
|
||||
|
||||
@@ -72,6 +72,9 @@ export const zhHK: Record<string, string> = {
|
||||
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
|
||||
openFileNewTab: '在新 Tab 中開啟',
|
||||
openFileSide: '在側邊開啟',
|
||||
zipProgress: '正在壓縮 {done}/{total}',
|
||||
settingsOpenWithPluginTitle: '永遠顯示插件自帶的開啟方式',
|
||||
settingsOpenWithPluginDesc: '預設只在宿主偵測不到本機關聯應用程式時顯示插件自帶的開啟方式(檔案總管 / VS Code / Cursor / Zed / 自訂編輯器、SSH 遠端);開啟後宿主應用程式與插件目標並存',
|
||||
zipDownload: '壓縮並下載',
|
||||
zipDownloadCount: '壓縮並下載({count} 項)',
|
||||
zipFailed: '壓縮失敗:{message}',
|
||||
|
||||
@@ -72,6 +72,9 @@ export const zhMO: Record<string, string> = {
|
||||
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
|
||||
openFileNewTab: '在新 Tab 中開啟',
|
||||
openFileSide: '在側邊開啟',
|
||||
zipProgress: '正在壓縮 {done}/{total}',
|
||||
settingsOpenWithPluginTitle: '永遠顯示插件自帶的開啟方式',
|
||||
settingsOpenWithPluginDesc: '預設只在宿主偵測不到本機關聯應用程式時顯示插件自帶的開啟方式(檔案總管 / VS Code / Cursor / Zed / 自訂編輯器、SSH 遠端);開啟後宿主應用程式與插件目標並存',
|
||||
zipDownload: '壓縮並下載',
|
||||
zipDownloadCount: '壓縮並下載({count} 項)',
|
||||
zipFailed: '壓縮失敗:{message}',
|
||||
|
||||
@@ -72,6 +72,9 @@ export const zhTW: Record<string, string> = {
|
||||
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
|
||||
openFileNewTab: '在新 Tab 中開啟',
|
||||
openFileSide: '在側邊開啟',
|
||||
zipProgress: '正在壓縮 {done}/{total}',
|
||||
settingsOpenWithPluginTitle: '永遠顯示插件自帶的開啟方式',
|
||||
settingsOpenWithPluginDesc: '預設只在宿主偵測不到本機關聯應用程式時顯示插件自帶的開啟方式(檔案總管 / VS Code / Cursor / Zed / 自訂編輯器、SSH 遠端);開啟後宿主應用程式與插件目標並存',
|
||||
zipDownload: '壓縮並下載',
|
||||
zipDownloadCount: '壓縮並下載({count} 項)',
|
||||
zipFailed: '壓縮失敗:{message}',
|
||||
|
||||
@@ -69,6 +69,9 @@ export const zh = {
|
||||
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
|
||||
openFileNewTab: '在新 Tab 中打开',
|
||||
openFileSide: '在侧边打开',
|
||||
zipProgress: '正在打包 {done}/{total}',
|
||||
settingsOpenWithPluginTitle: '始终显示插件自研的打开方式',
|
||||
settingsOpenWithPluginDesc: '默认只在宿主探测不到本机关联应用时显示插件自研的打开方式(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器、SSH 远端);打开后宿主应用与插件目标并存',
|
||||
zipDownload: '压缩并下载',
|
||||
zipDownloadCount: '压缩并下载({count} 项)',
|
||||
zipFailed: '打包失败:{message}',
|
||||
@@ -580,6 +583,9 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing',
|
||||
openFileNewTab: 'Open in New Tab',
|
||||
openFileSide: 'Open to the Side',
|
||||
zipProgress: 'Archiving {done}/{total}',
|
||||
settingsOpenWithPluginTitle: 'Always show the plugin\u2019s own Open with targets',
|
||||
settingsOpenWithPluginDesc: 'By default the plugin\u2019s own targets (file manager / VS Code / Cursor / Zed / custom editors, SSH remotes) show only when the host reports no local associated application; turn this on to keep both side by side',
|
||||
zipDownload: 'Zip and download',
|
||||
zipDownloadCount: 'Zip and download ({count} items)',
|
||||
zipFailed: 'Archive failed: {message}',
|
||||
|
||||
@@ -26,8 +26,8 @@ import type { NativeTabRecords } from './tab-adapter.tsx'
|
||||
|
||||
/** One open the surface could not place yet. */
|
||||
type Pending =
|
||||
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
|
||||
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
|
||||
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean; preferNewPane: boolean }
|
||||
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean; preferNewPane: boolean }
|
||||
|
||||
/**
|
||||
* The observation "which session's seat is on screen": DSH 0.1.7 publishes it
|
||||
@@ -42,14 +42,14 @@ export interface MountedSessions {
|
||||
|
||||
/** The controller face this module uses (a structural slice of `ISidebarRight`). */
|
||||
interface NativeController {
|
||||
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void
|
||||
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void
|
||||
close(tabId: string): void
|
||||
/** The mounted-seat observation (0.1.7 `ISidebarRight.mounted`). */
|
||||
mounted?: MountedSessions
|
||||
/** Not part of `ISidebarRight`: the concrete controller's per-session writes. */
|
||||
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
|
||||
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void
|
||||
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void
|
||||
closeIn?(sessionId: string, tabId: string): void
|
||||
}
|
||||
|
||||
@@ -134,7 +134,11 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
|
||||
if (api === undefined) return false
|
||||
const onScreen = mountedSessionId(ctx) === entry.sessionId
|
||||
if (entry.kind === 'tab') {
|
||||
const options = { params: entry.params, revealIfOpened: entry.revealIfOpened }
|
||||
const options = {
|
||||
params: entry.params,
|
||||
revealIfOpened: entry.revealIfOpened,
|
||||
...(entry.preferNewPane ? { preferNewPane: true } : {}),
|
||||
}
|
||||
if (onScreen) {
|
||||
api.openTab(entry.tabKind, options)
|
||||
return true
|
||||
@@ -148,6 +152,7 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
|
||||
const options = {
|
||||
...(entry.line === undefined ? {} : { params: { line: entry.line } }),
|
||||
revealIfOpened: entry.revealIfOpened,
|
||||
...(entry.preferNewPane ? { preferNewPane: true } : {}),
|
||||
}
|
||||
if (onScreen) {
|
||||
api.openResource(entry.address, options)
|
||||
@@ -185,11 +190,11 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
|
||||
}
|
||||
const unsubscribeList = ctx.sessions.list.subscribe(onListChange)
|
||||
return {
|
||||
openTab({ sessionId, kind, params, revealIfOpened }) {
|
||||
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened })
|
||||
openTab({ sessionId, kind, params, revealIfOpened, preferNewPane }) {
|
||||
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened, preferNewPane: preferNewPane === true })
|
||||
},
|
||||
openResource({ sessionId, address, line, revealIfOpened }) {
|
||||
enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened })
|
||||
openResource({ sessionId, address, line, revealIfOpened, preferNewPane }) {
|
||||
enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened, preferNewPane: preferNewPane === true })
|
||||
},
|
||||
fileAddress(sessionId, cwd, path) {
|
||||
return fileAddressFor(sessionId, cwd, path)
|
||||
|
||||
+19
-5
@@ -416,8 +416,14 @@ export interface OpenTabSeed {
|
||||
* the plugin's content is registered there as native tab types; `'bottom'`
|
||||
* is the plugin's own bottom workbench. Only the plugin's own flows pass
|
||||
* `'bottom'` (the bottom panel's + menu, the auto-terminal).
|
||||
*
|
||||
* `'side'` also means the right Sidebar, but it lands in a SECOND pane
|
||||
* there (`preferNewPane`, the host's own split): that is the "open to the
|
||||
* side" action, which must not fall back to the bottom workbench a native
|
||||
* tab never lives in. A path-less editor seed is the file explorer page, so
|
||||
* `'side'` only changes where a path seed lands.
|
||||
*/
|
||||
target?: 'right' | 'bottom'
|
||||
target?: 'right' | 'bottom' | 'side'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,9 +455,9 @@ export interface NativeTabParams {
|
||||
*/
|
||||
export interface SidebarSurface {
|
||||
/** Open a page type in one session's native surface. */
|
||||
openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean }): void
|
||||
openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean; preferNewPane?: boolean }): void
|
||||
/** Open a resource address in one session's native surface. */
|
||||
openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean }): void
|
||||
openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean; preferNewPane?: boolean }): void
|
||||
/** The file address of one path (the native surface owns the grammar). */
|
||||
fileAddress(sessionId: string, cwd: string | undefined, path: string): string
|
||||
/** Close one native tab; the closed record's type/title, or undefined when the id is not native. */
|
||||
@@ -900,6 +906,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
// included) as navigation params, which the tab adapter merges onto the
|
||||
// synthetic record's `tab.path` for the registered component.
|
||||
if (surface !== undefined && seed.target !== 'bottom') {
|
||||
// "Open to the side" asks the host for a NEW pane instead of reusing
|
||||
// the pane the acting tab lives in. `revealIfOpened: false` permits a
|
||||
// duplicate of an already-open resource, so the split really happens
|
||||
// (the host's `preferNewPane` falls back to the target pane when no
|
||||
// split is available — that fallback is the host's rule, not ours).
|
||||
const side = seed.target === 'side'
|
||||
const state = store.getSnapshot().state
|
||||
// The descriptor's own factory mints what a view needs beyond the seed:
|
||||
// the side chat's thread bootstrap / reattach meta, the terminal's
|
||||
@@ -926,7 +938,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
surface.openResource({
|
||||
sessionId: targetSessionId,
|
||||
address: surface.fileAddress(targetSessionId, scope?.cwd, seed.path),
|
||||
revealIfOpened: true,
|
||||
revealIfOpened: side ? false : true,
|
||||
...(side ? { preferNewPane: true } : {}),
|
||||
})
|
||||
} else {
|
||||
// The path-less editor window IS the file explorer.
|
||||
@@ -946,7 +959,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
...(seed.diff === undefined ? {} : { diff: seed.diff }),
|
||||
...(synthetic.meta === undefined ? {} : { meta: synthetic.meta }),
|
||||
},
|
||||
revealIfOpened,
|
||||
revealIfOpened: side ? false : revealIfOpened,
|
||||
...(side ? { preferNewPane: true } : {}),
|
||||
})
|
||||
}
|
||||
// The native surface reports one open event, not create-vs-focus, so a
|
||||
|
||||
@@ -832,19 +832,46 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The multi-selection bar: the kit's 28px section band, sat above the root
|
||||
row (it never covers a row, so the tree stays fully aimable). Only the
|
||||
fill/rhythm differ from the kit band — geometry stays the kit's. */
|
||||
/* The multi-selection bar: the LAST child of the tree's scroll container, so
|
||||
it docks at the panel's bottom edge (sticky) and its appearance never
|
||||
pushes the rows around the way a top bar did. Sticky (not absolute) keeps
|
||||
it in flow: scrolled to the bottom it sits below the last row and covers
|
||||
nothing, while mid-scroll it stays reachable. It WRAPS instead of
|
||||
overflowing, so a narrow panel gets a second line of chips rather than a
|
||||
clipped control or a horizontal scrollbar. 28px rhythm, token-only (§1). */
|
||||
.explorerSelectionBar {
|
||||
margin-bottom: 4px;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.explorerSelectionText {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.explorerSelectionActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
max-width: 72%;
|
||||
}
|
||||
|
||||
/* The leading glyph of one "open in app" menu entry (the host's icon route
|
||||
|
||||
+45
-38
@@ -38,8 +38,8 @@ import { isTrustedApiRequest } from './trust-fence.ts'
|
||||
import { registerBundleRoute } from './bundle-route.ts'
|
||||
import { createDirectoryWatchers, type DirectoryWatchers } from './fs-watch.ts'
|
||||
import { launchExternal } from './open-external.ts'
|
||||
import { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArchiveNames } from './archive-route.ts'
|
||||
import { buildZip, type ZipEntry } from './zip.ts'
|
||||
import { archiveNameOf, collectZipEntries, createArchiveTasks, disambiguateArchiveNames, respondArchiveDownload, type ArchiveTasks } from './archive-route.ts'
|
||||
import type { ZipEntry } from './zip.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
|
||||
import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './agent-opens.ts'
|
||||
@@ -72,7 +72,6 @@ export type {
|
||||
// their bounds live inside the collector, so the suite needs them observable
|
||||
// without creating 10 000 rows first.
|
||||
export { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArchiveNames } from './archive-route.ts'
|
||||
|
||||
/** Plugin identity for cordis.yml rows. */
|
||||
export const name = 'dsh-better-sidebar'
|
||||
|
||||
@@ -254,6 +253,7 @@ function buildApi(
|
||||
resolved: ResolvedSidebarConfig,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
assistantLive: AssistantLiveBuffer,
|
||||
archiveTasks: ArchiveTasks,
|
||||
): Record<string, ApiMethod> {
|
||||
const cwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
@@ -549,6 +549,32 @@ function buildApi(
|
||||
if (action === 'url') return launchExternal('url', requireString(payload, 'url'))
|
||||
throw new SidebarError('bad-request', 'action must be "reveal" or "url"')
|
||||
},
|
||||
// Archive builds: collect the selection (fenced + disambiguated) and hand
|
||||
// the zipping to the background task table, so the tree can show progress
|
||||
// through `archive.status` and download the finished bytes from
|
||||
// GET /sidebar/archive?id=.
|
||||
'archive.build': async (payload) => {
|
||||
const { sessionId, cwd } = await cwdOf(payload)
|
||||
const record = payload as { paths?: unknown; name?: unknown } | null
|
||||
const paths = Array.isArray(record?.paths)
|
||||
? record.paths.filter((value): value is string => typeof value === 'string' && value !== '')
|
||||
: []
|
||||
if (paths.length === 0) throw new SidebarError('bad-request', 'paths must be a non-empty array')
|
||||
const name = archiveNameOf(typeof record?.name === 'string' ? record.name : null)
|
||||
const fenceOn = fenceEnabledOf(getSettings)
|
||||
const selected: string[] = []
|
||||
for (const raw of paths) selected.push(await ensureWorkspacePath(cwd, raw, fenceOn))
|
||||
// Same-basename selections (a/index.ts + b/index.ts) get parent segments
|
||||
// prepended, so no two archive members collide.
|
||||
const entries: ZipEntry[] = []
|
||||
const names = disambiguateArchiveNames(selected)
|
||||
for (const [index, absolute] of selected.entries()) {
|
||||
await collectZipEntries(absolute, names[index]!, entries)
|
||||
}
|
||||
return archiveTasks.start({ sessionId, name, entries })
|
||||
},
|
||||
'archive.status': (payload) =>
|
||||
archiveTasks.status(requireString(payload, 'id'), requireString(payload, 'sessionId')),
|
||||
// Side Chat: create a side-thread child seeded with the parent's full
|
||||
// log up to now, deliver follow-ups (cold-resuming when the thread's
|
||||
// agent is gone), abort a running thread, and release a thread's agent.
|
||||
@@ -837,7 +863,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// effect releases the listener on fiber disposal.
|
||||
const assistantLive = createAssistantLiveBuffer(ctx)
|
||||
ctx.effect(() => () => { assistantLive.dispose() }, 'dsh-better-sidebar: live assistant stream buffer')
|
||||
const api = buildApi(ctx, resolved, () => settingsFace, assistantLive)
|
||||
// One archive task table per mount: the `archive.build` / `archive.status`
|
||||
// API methods write it, the GET /sidebar/archive route reads it.
|
||||
const archiveTasks = createArchiveTasks()
|
||||
const api = buildApi(ctx, resolved, () => settingsFace, assistantLive, archiveTasks)
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/api',
|
||||
@@ -917,12 +946,14 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// (see bundle-route.ts / src/client/chunk-loader.ts).
|
||||
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
|
||||
|
||||
// ── Archive route (zip one selection and download it) ───────────────────
|
||||
// The file tree's "zip and download" action: every `path` is resolved
|
||||
// through the SAME workspace fence as fs.tree / /sidebar/file, directories
|
||||
// are walked recursively (a symlink is recorded as a link, never followed —
|
||||
// no escape and no cycle), and the whole archive is built in memory and
|
||||
// answered in one response. The entry-count/byte bounds live in zip.ts.
|
||||
// ── Archive routes (zip one selection, watch it build, download it) ─────
|
||||
// The file tree's "zip and download" action is a three-step flow: POST
|
||||
// `archive.build` fences every `path` through the SAME workspace fence as
|
||||
// fs.tree / /sidebar/file, walks the directories (a symlink is skipped, never
|
||||
// followed — no escape and no cycle) and returns an id IMMEDIATELY; the
|
||||
// background build reports progress through `archive.status`; and
|
||||
// GET /sidebar/archive serves the finished bytes once, with the RFC 5987
|
||||
// disposition. `archiveTasks` owns the concurrency cap and the TTL.
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/sidebar/archive',
|
||||
@@ -937,34 +968,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
}
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
const paths = url.searchParams.getAll('path').filter(value => value !== '')
|
||||
if (sessionId === null || paths.length === 0) {
|
||||
throw new SidebarError('bad-request', 'sessionId and at least one path are required')
|
||||
}
|
||||
const name = archiveNameOf(url.searchParams.get('name'))
|
||||
const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const fenceOn = fenceEnabledOf(() => settingsFace)
|
||||
const selected: string[] = []
|
||||
for (const raw of paths) selected.push(await ensureWorkspacePath(cwd, raw, fenceOn))
|
||||
// Same-basename selections (a/index.ts + b/index.ts) get parent
|
||||
// segments prepended, so no two archive members collide and an
|
||||
// extractor cannot overwrite one with the other.
|
||||
const entries: ZipEntry[] = []
|
||||
const names = disambiguateArchiveNames(selected)
|
||||
for (const [index, absolute] of selected.entries()) {
|
||||
await collectZipEntries(absolute, names[index]!, entries)
|
||||
}
|
||||
const body = await buildZip(entries)
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/zip',
|
||||
// Never the raw name: a non-latin1 value (中文目录 → 报告.zip) makes
|
||||
// Node's writeHead throw and turns the download into a 500.
|
||||
'content-disposition': contentDispositionOf(name),
|
||||
'content-length': String(body.byteLength),
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
res.end(body)
|
||||
respondArchiveDownload(archiveTasks, {
|
||||
sessionId: url.searchParams.get('sessionId'),
|
||||
id: url.searchParams.get('id'),
|
||||
}, res)
|
||||
} catch (error) {
|
||||
writeError(res, error)
|
||||
}
|
||||
|
||||
+23
-1
@@ -31,13 +31,26 @@ export interface ZipEntry {
|
||||
isDir?: boolean
|
||||
}
|
||||
|
||||
/** The archive bounds. */
|
||||
/** One progress report: entries finished, entries total, bytes read so far. */
|
||||
export interface ZipProgress {
|
||||
/** Entries fully processed (payload read, compressed, hashed). */
|
||||
done: number
|
||||
/** Entries the archive will contain (known up front: the entries array). */
|
||||
total: number
|
||||
/** Uncompressed bytes accumulated so far. */
|
||||
bytes: number
|
||||
}
|
||||
|
||||
/** The archive bounds and the optional progress hook. */
|
||||
export interface ZipOptions {
|
||||
/** Total uncompressed byte bound; exceeding it throws fs-error. */
|
||||
maxBytes?: number
|
||||
/** Entry-count bound (directory entries included); exceeding it throws
|
||||
* fs-error. */
|
||||
maxEntries?: number
|
||||
/** Called once per finished entry, in archive order (never for a failed
|
||||
* one). A throw from the hook aborts the build. */
|
||||
onProgress?: (progress: ZipProgress) => void
|
||||
}
|
||||
|
||||
/** Default total uncompressed payload bound of one archive (256 MiB). */
|
||||
@@ -205,9 +218,16 @@ export async function buildZip(entries: readonly ZipEntry[], opts: ZipOptions =
|
||||
const members: ZipMember[] = []
|
||||
/** Total uncompressed payload (the bound is checked against it). */
|
||||
let totalBytes = 0
|
||||
/** Entries finished so far (the progress hook's `done`). */
|
||||
let done = 0
|
||||
const report = (): void => {
|
||||
opts.onProgress?.({ done, total: entries.length, bytes: totalBytes })
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDir === true) {
|
||||
members.push(directoryMember(archiveName(entry.name, true)))
|
||||
done += 1
|
||||
report()
|
||||
continue
|
||||
}
|
||||
const path = requireSourcePath(entry.path)
|
||||
@@ -222,6 +242,8 @@ export async function buildZip(entries: readonly ZipEntry[], opts: ZipOptions =
|
||||
}
|
||||
totalBytes += member.size
|
||||
members.push(member)
|
||||
done += 1
|
||||
report()
|
||||
}
|
||||
|
||||
const out: Buffer[] = []
|
||||
|
||||
Reference in New Issue
Block a user