feat(sidebar): 打开方式双源并存 + 多选压缩下载 + 文件变动层级树
用户追加三条需求,全部落地: 1) 打开方式 = 宿主探测应用 + 插件自研 open-with(并存) - 逐字恢复 src/client/open-with.ts / open-with-settings.tsx / plugin-settings.ts / src/open-external.ts / open.external 路由 / api.openExternal / editor descriptor 的 settings.render,以及 20 个 openWith* 词条(19 份词典从 main 取回原文) - 恢复 menu-flip.ts + layout.css 子菜单翻转规则(子菜单回来了) - FileTree 菜单段(无标题行):宿主行 → 分隔线 → 插件 pinned + 「在应用中打开 >」 子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主 reveal;宿主可用时过滤插件 explorer 目标,宿主不可用时插件行照旧(SSH 远端场景保持可用) - 死键清理:菜单去掉标题行后 openInApp 词条无引用,已从 21 份词典删除 2) 文件页多选右键「压缩并打包下载」 - 新增 src/zip.ts(自研 ZIP:方法 0/8 按收益二选一、UTF-8 bit 11、目录条目、 自实现 CRC32、无第三方依赖)+ src/archive-route.ts(名净化 + 递归收集)+ GET /sidebar/archive(同一道 workspace fence、目录递归跳过符号链接、 10000 条目 / 256 MiB 上限)+ api.archiveUrl - FileTree:多选 ≥2 项或单选目录时出现;fetch → blob → objectURL 下载, 路由错误(超限 / fence / 非 GET)以 zipFailed 显示在错误条 - 修掉真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix, Info-ZIP 会把每个文件解成 mode 000;改为 Unix 语义(0o100644 * 0x10000, << 16 会溢出 int32)。系统 unzip 复验:mode 644/755、unzip -t 无错 3) 文件变动页层级树 + 视觉打磨 - 新增 src/client/changes/change-tree.ts:单子目录链压缩成一行、目录先于文件、 大小写不敏感排序、文件带 GitFileStatus、目录带下级变更数 - GitLens 两组各自渲染树:折叠状态按「组+路径」独立、默认展开;分组头吸顶 + 计数胶囊;Stage all/Unstage all 改为带 aria-label 的 IconButton;选中 = interactive-bg-active + 2px 内嵌强调条;12px/级缩进 + l1 参考线; 历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐 验证:typecheck / lint / test(134 文件 1422 通过)/ build 全绿; DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过; 真实宿主截图确认三条需求(多选+双源菜单+zip 项、变动页树形层级)
This commit is contained in:
+256
-33
@@ -42,16 +42,21 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseFillRegular, IconCodeOutlineRegular, IconCopyOutlineRegular, IconDownloadOutlineRegular,
|
||||
IconArchiveOutlineRegular, IconChevronRightOutlineRegular, IconCloseFillRegular, IconCodeOutlineRegular, IconCopyOutlineRegular,
|
||||
IconDownloadOutlineRegular,
|
||||
IconEditOutlineRegular, IconFolderOpenRegular, IconLinkOutlineRegular, IconPlusOutlineRegular, IconTrashOutlineRegular,
|
||||
Menu, type MenuEntry, type MenuItem, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
|
||||
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 { FenceErrorNotice } from './FenceErrorNotice.tsx'
|
||||
import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||
import { isImeComposition } from './ime-guard.ts'
|
||||
import { useSubmenuFlip } from './menu-flip.ts'
|
||||
import type { OpenInApp, OpenInAppEntry } from './open-in-app.ts'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { BetterSidebarService } from './service.ts'
|
||||
@@ -344,9 +349,23 @@ export function FileTree(props: {
|
||||
/**
|
||||
* The host's open-in-app handle (EditorHost builds it and injects it).
|
||||
* Absent, or reporting the host cannot hand paths to the desktop, hides the
|
||||
* whole "open in app" section.
|
||||
* HOST half of the "打开方式" section (the plugin's own targets stay).
|
||||
*/
|
||||
openInApp?: OpenInApp
|
||||
/**
|
||||
* The PLUGIN's own "open with" menu: resolved external targets (already
|
||||
* SSH-filtered and in menu order). Absent → no plugin half. Coexists with
|
||||
* {@link openInApp} — the section lists both.
|
||||
*/
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
/** Ids of targets pinned to the menu's top level (subset of the ids). */
|
||||
openWithPinned?: string[]
|
||||
/** Whether the workspace is remote (appends the SSH hint to target labels). */
|
||||
openWithSsh?: boolean
|
||||
/** Open one plugin target externally (reveal or URL — the caller decides). */
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
/** Toggle one plugin target's pinned state (the submenu row's pushpin). */
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft (file vs directory). */
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
/** A rename landed (old row path → new path): the caller retargets open tabs. */
|
||||
@@ -382,7 +401,8 @@ export function FileTree(props: {
|
||||
// callbacks must not change identity when the caller re-renders).
|
||||
const {
|
||||
sessionId, cwd, store, expanded, revealed, onOpenFileNewTab, onOpenFileSide,
|
||||
openInApp, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service,
|
||||
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
|
||||
onReferenceFile, 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)
|
||||
@@ -414,6 +434,9 @@ export function FileTree(props: {
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
||||
// The plugin submenu can tower past the viewport; publish its flip geometry
|
||||
// for layout.css while the row menu is open.
|
||||
useSubmenuFlip(rowMenu)
|
||||
/** The open-in-app rows for the open menu (null entries = listing failed). */
|
||||
const [apps, setApps] = useState<{ path: string; entries: readonly OpenInAppEntry[] | null } | null>(null)
|
||||
/** The row being renamed inline: its path plus the edit buffer. */
|
||||
@@ -892,12 +915,6 @@ export function FileTree(props: {
|
||||
setActionError(t('openInAppFailed', { path }))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 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). Availability is re-read per open — a host that cannot hand
|
||||
* paths to the desktop hides the section entirely.
|
||||
*/
|
||||
/**
|
||||
* Whether the host can hand paths to a native desktop — probed ONCE per
|
||||
* handle, on mount. The adapter starts in its `null` (unprobed) state, and
|
||||
@@ -928,6 +945,12 @@ export function FileTree(props: {
|
||||
return () => { cancelled = true }
|
||||
}, [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.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (rowMenu === null || openInApp === undefined || !appReady) {
|
||||
setApps(null)
|
||||
@@ -962,22 +985,110 @@ export function FileTree(props: {
|
||||
void handle.reveal(path).then((ok) => { if (!ok) reportOpenFailure(path) }).catch(() => { reportOpenFailure(path) })
|
||||
}, [reportOpenFailure])
|
||||
|
||||
/** The menu label of one plugin open target: a locale key for the built-ins,
|
||||
* the user's own name for custom editors, plus the SSH hint in remote mode. */
|
||||
const openWithLabelOf = (target: OpenWithTarget): string => {
|
||||
const name = target.nameKey !== undefined ? t(target.nameKey) : target.name
|
||||
return openWithSsh === true && !target.localOnly ? `${name}${t('openWithSshSuffix')}` : name
|
||||
}
|
||||
|
||||
/**
|
||||
* The menu's "open in app" section for one row: a heading label plus the
|
||||
* host's rows (the default-application row first for files), a separator,
|
||||
* and reveal-in-file-manager. Absent when the caller wired no handle or the
|
||||
* host cannot open desktop paths; an empty/failed listing degrades to one
|
||||
* disabled line so the section never silently disappears mid-interaction.
|
||||
* 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.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
const openInAppEntries = (target: { path: string; isDir: boolean }): MenuEntry[] => {
|
||||
if (openInApp === undefined || !appReady) return []
|
||||
const head: MenuEntry = { type: 'label', id: 'open-in-app-label', text: t('openInApp') }
|
||||
const entries = apps !== null && apps.path === target.path ? apps.entries : undefined
|
||||
if (entries === undefined) return [head, { id: 'open-in-app-loading', label: t('loading'), disabled: true }]
|
||||
if (entries === null || entries.length === 0) {
|
||||
return [head, { id: 'open-in-app-empty', label: t('openInAppEmpty'), disabled: true }]
|
||||
const openWithEntries = (hostReady: boolean): MenuEntry[] => {
|
||||
if (openWithTargets === undefined || onOpenWith === undefined) return []
|
||||
const targets = hostReady ? openWithTargets.filter(target => target.id !== 'explorer') : openWithTargets
|
||||
if (targets.length === 0) return []
|
||||
const pinnedIds = openWithPinned ?? []
|
||||
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
|
||||
* reveal gets the folder glyph, custom editors a generic code mark.
|
||||
* The compact menu's icon slot is 14px, so every mark renders at 14. */
|
||||
const itemIcon = (target: OpenWithTarget): ReactNode => {
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={14} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={14} />
|
||||
if (target.id === 'cursor') return <SiCursor size={14} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={14} />
|
||||
return <IconCodeOutlineRegular size={14} />
|
||||
}
|
||||
const rows: MenuItem[] = []
|
||||
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 => {
|
||||
const pinnedNow = pinnedIds.includes(target.id)
|
||||
return {
|
||||
id: `open-with:${target.id}`,
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{openWithLabelOf(target)}</span>
|
||||
{/* The pushpin: a span (never a button — the Menu row itself is
|
||||
a button, so a nested interactive element would be invalid).
|
||||
Clicking it pins/unpins the target at the menu's top level
|
||||
WITHOUT selecting the row: the pin stops propagation, so the
|
||||
menu stays open and the icon flips on the next render. */}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(css.openWithPin, pinnedNow && css.openWithPinActive)}
|
||||
aria-label={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
|
||||
title={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggleOpenWithPin?.(target.id)
|
||||
}}
|
||||
>
|
||||
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
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 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.
|
||||
*/
|
||||
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 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} /> })
|
||||
@@ -986,12 +1097,60 @@ export function FileTree(props: {
|
||||
if (!target.isDir && entry.isDefault) continue
|
||||
rows.push({ id: `open-in-app:${entry.id}`, label: entry.name, icon: <AppGlyph entry={entry} /> })
|
||||
}
|
||||
return [
|
||||
head,
|
||||
...rows,
|
||||
{ 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
|
||||
* only duplicate the plain download row.
|
||||
*/
|
||||
const zipEntries = (target: { path: string; isDir: boolean }): MenuEntry[] => {
|
||||
const count = selected.size
|
||||
if (count >= 2) {
|
||||
return [{
|
||||
id: 'archive-selection',
|
||||
label: t('zipDownloadCount', { count }),
|
||||
icon: <IconArchiveOutlineRegular size={14} />,
|
||||
}]
|
||||
}
|
||||
if (count === 1 && target.isDir) {
|
||||
return [{ id: 'archive-selection', label: t('zipDownload'), icon: <IconArchiveOutlineRegular size={14} /> }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Download a file through the host route (raw bytes, binary-safe). */
|
||||
@@ -1005,6 +1164,59 @@ export function FileTree(props: {
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. `archiveBusyRef` keeps a double click from
|
||||
* packaging the same selection twice.
|
||||
*/
|
||||
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
|
||||
void fetch(url)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const envelope: { error?: { message?: string } } | null = await response.json().catch(() => null)
|
||||
throw new Error(envelope?.error?.message ?? `HTTP ${response.status}`)
|
||||
}
|
||||
return await response.blob()
|
||||
})
|
||||
.then((blob) => {
|
||||
setActionError(null)
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = objectUrl
|
||||
anchor.download = name
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
// Revoke on the next task, not synchronously: some engines have not
|
||||
// committed the download when click() returns, and revoking the URL
|
||||
// under them loses the file. One tick is enough and keeps the blob
|
||||
// from outliving the download.
|
||||
window.setTimeout(() => { URL.revokeObjectURL(objectUrl) }, 0)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setActionError(t('zipFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
})
|
||||
.finally(() => { archiveBusyRef.current = false })
|
||||
}
|
||||
|
||||
const root = cwd
|
||||
|
||||
// Membership is tested per rendered row (deep trees run this thousands of
|
||||
@@ -1357,7 +1569,7 @@ export function FileTree(props: {
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpenRegular size={14} /> }]
|
||||
: []),
|
||||
...(rowMenu === null ? [] : openInAppEntries(rowMenu)),
|
||||
...(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} /> }]
|
||||
@@ -1370,6 +1582,8 @@ export function FileTree(props: {
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'new-folder', label: t('newFolder'), icon: <IconPlusOutlineRegular size={14} /> }]
|
||||
: []),
|
||||
// ZIP of the current selection (≥2 rows, or one lone directory).
|
||||
...(rowMenu === null ? [] : zipEntries(rowMenu)),
|
||||
{ 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
|
||||
@@ -1402,10 +1616,20 @@ export function FileTree(props: {
|
||||
openWithApp(target.path, id.slice('open-in-app:'.length))
|
||||
return
|
||||
}
|
||||
// The plugin's own targets share one id space (pinned rows and
|
||||
// submenu children alike), so the caller gets the target id + path.
|
||||
if (id.startsWith('open-with:')) {
|
||||
onOpenWith?.(id.slice('open-with:'.length), target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'reveal-in-file-manager') {
|
||||
revealPath(target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'archive-selection') {
|
||||
downloadArchive([...selected])
|
||||
return
|
||||
}
|
||||
if (id === 'download') {
|
||||
downloadFile(target.path)
|
||||
return
|
||||
@@ -1418,8 +1642,7 @@ export function FileTree(props: {
|
||||
if (id === 'new-folder') {
|
||||
startNewFolder(target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'rename') {
|
||||
} if (id === 'rename') {
|
||||
setRenaming({ path: target.path, value: baseName(target.path) })
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user