Files
apes-sidebar/src/client/tree-mutations.ts
T
Menghuan1918 e39ea92ade feat(files): tree rename (inline) + delete (confirmed) + submenu visual tether
资源管理器补全:文件树右键菜单新增「重命名」(行内编辑:预填+全选、
Enter/失焦提交、Esc 取消、IME 组合键守卫)与「删除」(Modal 确认,文件/
目录文案区分,永久删除——宿主无回收站)。根行(工作区根)不提供两项,
服务端 fs.rename/fs.remove 双保险拒绝;目标已存在 409;link-aware——
symlink 行只改/删链接本身(lstat 判定),fence 校验对解析后目标进行。

落定收尾:pruneTree 清旧前缀缓存 + 收起 stale expanded + 单层重载父目录;
tab 调解走 service(rename→updateTab 改路径改标题、delete→closeTab 关
受影响 tab,覆盖两侧分栏/底栏/浮动窗口,经 tree-mutations.ts)。

二级菜单视觉关系(layout.css,仍限 data-dsh-sidebar-submenu 作用域):
父行在子菜单打开期间保持高亮(宿主已渲染 aria-expanded="true"),
卡片间距 10px→4px(悬停走廊同步收窄),翻转规则一并 4px。

i18n 新增 7 key × 20 语言文件(locales.spec 的 key-set-parity 门禁)。
测试:fs-operations 追加 12 例(rename/remove 全失败面);新增
file-tree-rename-delete.spec.tsx 8 例(菜单可见性/重命名流/删除流/错误条);
menu-flip.spec.tsx 契约扩展覆盖 tether 规则。设计文档
docs/plans/2026-09-04-file-tree-rename-delete-design.md。
2026-09-04 22:08:02 +08:00

51 lines
2.4 KiB
TypeScript

/**
* Open-tab reconciliation after a file-tree mutation (rename/delete).
*
* The tree owns the rows; the TABS live in the sidebar state (either split
* tree, the bottom panel, or free windows). A rename must retarget every
* tab whose `path` is the renamed file (the editor content survives and
* later saves land on the new path); a delete must close every tab at or
* under the removed path — files and anything inside a removed directory
* (a stale tab's next save would fail against a missing path). Both ride
* the SERVICE paths (`updateTab`/`closeTab`) rather than direct state
* reducers so the registered lifecycle callbacks fire like any other tab
* mutation.
*/
import type { Context } from '../context-types.ts'
import { baseName } from './FileTree.tsx'
import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts'
import { isWithinWorkspace } from './paths.ts'
/** Every open tab that carries a file path (either split tree, the bottom
* panel, and free windows — a floating tab is as open as a docked one). */
function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] {
const state = snapshot.state
if (state === undefined) return []
const tabs: SidebarTab[] = []
for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs)
for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs)
for (const float of state.floats) tabs.push(float.tab)
return tabs.filter(tab => tab.path !== undefined)
}
/** Retarget tabs after `oldPath` became `newPath` (title follows the new
* base name, matching how openFile titles editor tabs). */
export function retargetPathTabs(ctx: Context, store: SidebarStore, oldPath: string, newPath: string): void {
const service = ctx.get('betterSidebar')
if (service === undefined) return
for (const tab of pathTabsOf(store.getSnapshot())) {
if (tab.path === oldPath) service.updateTab(tab.id, { path: newPath, title: baseName(newPath) })
}
}
/** Close tabs at or under the removed `target` (a directory takes its whole
* subtree of open files with it). */
export function closePathTabs(ctx: Context, store: SidebarStore, target: string): void {
const service = ctx.get('betterSidebar')
if (service === undefined) return
for (const tab of pathTabsOf(store.getSnapshot())) {
const path = tab.path
if (path !== undefined && (path === target || isWithinWorkspace(target, path))) service.closeTab(tab.id)
}
}