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:
Menghuan1918
2026-09-28 20:11:46 +08:00
parent 13d4bae321
commit ee1fe05067
41 changed files with 2228 additions and 597 deletions
+24
View File
@@ -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
View File
@@ -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,
+6
View File
@@ -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
View File
@@ -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). */
+8
View File
@@ -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} />
),
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+6
View File
@@ -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}',
+16 -11
View File
@@ -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
View File
@@ -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
+33 -6
View File
@@ -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