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。
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user