@young1lin/dsh-ui-gitworkbench 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  本文件记录面向使用者的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [0.1.5] - 2026-08-18
6
+
7
+ ### 修复
8
+
9
+ - **目录行的撤回直接失败(EISDIR)**。`git status` 不会走进另一个仓库,嵌套的未跟踪仓库即使带 `--untracked-files=all` 也只报一行 `?? sub/`——那是一个**目录**行,而删除步骤没带 `recursive`,于是抽屉里唯独这一种行的撤回以 `EISDIR` 告终。删除步骤现在单独成模块(`src/fs-remove.ts`),路径校验与真实文件系统行为都有测试盖着。
10
+ - **撤回失败不再静默**。“这个文件撤回会发生什么”这一问失败时,之前与“git 说这个文件本来就没改动”返回同一个值,两者都只是静静地刷新——点下去看不出与按钮坏了有什么区别,而按钮坏了的自然反应是再点一次。现在失败把 git 自己的话抬到操作横幅上,“没什么可撤”仍然只刷新(那一行自己会消失,这既是反馈也是修复)。
11
+ - **传输层报错不再把抽屉卡在“询问中”**。计划请求抛异常时,之前无人接手:弹窗永远不开,状态也不复位,只能关掉抽屉。
12
+ - **认不出的后果一律弹窗**。只有 host 明确标为可逆(`irreversible: false`,即“找回被删文件”)才跳过确认;比本包新的 host 报一个未知 effect 时,缺失的标记不再被当成“可逆”而直接动手。
13
+
5
14
  ## [0.1.4] - 2026-08-18
6
15
 
7
16
  ### 新增
package/CHANGELOG_EN.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  User-facing changes, newest first. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows SemVer.
4
4
 
5
+ ## [0.1.5] - 2026-08-18
6
+
7
+ ### Fixed
8
+
9
+ - **Rolling back a directory row failed outright (`EISDIR`)**. `git status` will not descend into another repository: a nested untracked repo is reported as a single `?? sub/` line even under `--untracked-files=all`, and that row names a **directory**. The delete step lacked `recursive`, so it was the one row in the drawer whose roll-back died with `EISDIR`. The delete now lives in its own module (`src/fs-remove.ts`) with tests over both the path checks and the real filesystem behaviour.
10
+ - **A failed roll-back no longer looks like a dead button**. Asking the host what rolling a file back would do used to return the same value whether it failed or whether git simply reported nothing to roll back, and both ended in a silent refresh. Failure now puts git's own words in the operation banner; "nothing to roll back" still just refreshes, since the row disappearing is both the feedback and the fix.
11
+ - **A transport error no longer strands the drawer mid-question**. A throw from the plan request had no handler: the dialog never opened and the state never cleared, leaving no way out but closing the drawer.
12
+ - **An unrecognised consequence now always confirms**. Only an explicit `irreversible: false` from the host (recovering a deleted file) skips the dialog; an effect newer than this bundle no longer reads a missing flag as "reversible" and act on it silently.
13
+
5
14
  ## [0.1.4] - 2026-08-18
6
15
 
7
16
  ### Added
package/lib/client.js CHANGED
@@ -790,6 +790,40 @@ window.__ModuleLoader__.load({
790
790
  return [...hits.filter((hit) => hit.isFile), ...hits.filter((hit) => !hit.isFile)];
791
791
  }
792
792
  //#endregion
793
+ //#region src/client/discard-flow.ts
794
+ /** Fallback text for a failure that arrived with nothing to say. */
795
+ const UNKNOWN_DISCARD_ERROR = "discardPlan failed";
796
+ /**
797
+ * Decide what a roll-back click does with the answer it got.
798
+ *
799
+ * @param answer - the host's reply, or the failure that replaced it.
800
+ * @returns the single next step; never null, because every answer including a
801
+ * broken one has to lead somewhere the reader can see.
802
+ */
803
+ function nextAfterPlan(answer) {
804
+ if (answer.kind === "failed") {
805
+ const error = answer.error.trim();
806
+ return {
807
+ kind: "report",
808
+ error: error.length > 0 ? error : UNKNOWN_DISCARD_ERROR
809
+ };
810
+ }
811
+ const plan = answer.plan;
812
+ if (typeof plan.error === "string" && plan.error.trim().length > 0) return {
813
+ kind: "report",
814
+ error: plan.error.trim()
815
+ };
816
+ if (plan.effect === void 0) return { kind: "refresh" };
817
+ if (plan.irreversible === false) return {
818
+ kind: "run",
819
+ effect: plan.effect
820
+ };
821
+ return {
822
+ kind: "confirm",
823
+ plan
824
+ };
825
+ }
826
+ //#endregion
793
827
  //#region src/client/file-filter.ts
794
828
  /**
795
829
  * Narrowing a file list by typing at it.
@@ -12214,262 +12248,262 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
12214
12248
  document.head.appendChild(tag);
12215
12249
  }
12216
12250
  var GitWorkbenchPanel_module_css_default = {
12217
- "syncSpacer": "SD8qLW_syncSpacer",
12218
12251
  "treeDirActive": "SD8qLW_treeDirActive",
12219
- "commits": "SD8qLW_commits",
12252
+ "funnelFootClear": "SD8qLW_funnelFootClear",
12253
+ "overlayMax": "SD8qLW_overlayMax",
12254
+ "cardBranch": "SD8qLW_cardBranch",
12255
+ "funnelMore": "SD8qLW_funnelMore",
12256
+ "lineHunk": "SD8qLW_lineHunk",
12257
+ "treeDirCounts": "SD8qLW_treeDirCounts",
12220
12258
  "btnAhead": "SD8qLW_btnAhead",
12221
- "commitTop": "SD8qLW_commitTop",
12222
- "commitPopSubject": "SD8qLW_commitPopSubject",
12223
- "segment": "SD8qLW_segment",
12224
- "funnelChevron": "SD8qLW_funnelChevron",
12225
- "resizerActive": "SD8qLW_resizerActive",
12226
- "segmented": "SD8qLW_segmented",
12227
- "commitMessage": "SD8qLW_commitMessage",
12228
- "signDel": "SD8qLW_signDel",
12229
- "themeRail": "SD8qLW_themeRail",
12230
- "cardAdded": "SD8qLW_cardAdded",
12231
- "refEmpty": "SD8qLW_refEmpty",
12232
- "file": "SD8qLW_file",
12233
- "elide": "SD8qLW_elide",
12234
- "sliderValue": "SD8qLW_sliderValue",
12235
- "fileBinary": "SD8qLW_fileBinary",
12236
- "wtCurrent": "SD8qLW_wtCurrent",
12259
+ "chipLight": "SD8qLW_chipLight",
12260
+ "commitSubject": "SD8qLW_commitSubject",
12261
+ "calHead": "SD8qLW_calHead",
12262
+ "funnelTabCount": "SD8qLW_funnelTabCount",
12263
+ "funnelSearch": "SD8qLW_funnelSearch",
12264
+ "commitPopMeta": "SD8qLW_commitPopMeta",
12265
+ "confirmBody": "SD8qLW_confirmBody",
12266
+ "headerView": "SD8qLW_headerView",
12267
+ "commit": "SD8qLW_commit",
12268
+ "lnNew": "SD8qLW_lnNew",
12269
+ "syncUpstream": "SD8qLW_syncUpstream",
12270
+ "bgPreview": "SD8qLW_bgPreview",
12237
12271
  "funnelBoundBtnActive": "SD8qLW_funnelBoundBtnActive",
12272
+ "card": "SD8qLW_card",
12238
12273
  "wordDel": "SD8qLW_wordDel",
12274
+ "commitRefMore": "SD8qLW_commitRefMore",
12239
12275
  "themeRowSplit": "SD8qLW_themeRowSplit",
12240
- "funnelBounds": "SD8qLW_funnelBounds",
12241
- "graphCell": "SD8qLW_graphCell",
12242
- "treeIcon": "SD8qLW_treeIcon",
12276
+ "cardFiles": "SD8qLW_cardFiles",
12277
+ "confirmActions": "SD8qLW_confirmActions",
12278
+ "cal": "SD8qLW_cal",
12279
+ "fileStatus": "SD8qLW_fileStatus",
12280
+ "themeNote": "SD8qLW_themeNote",
12281
+ "btnCount": "SD8qLW_btnCount",
12282
+ "sliderValue": "SD8qLW_sliderValue",
12283
+ "bgEmpty": "SD8qLW_bgEmpty",
12284
+ "cardGlyph": "SD8qLW_cardGlyph",
12285
+ "filterChipRemove": "SD8qLW_filterChipRemove",
12243
12286
  "scopeHint": "SD8qLW_scopeHint",
12244
- "paletteRow": "SD8qLW_paletteRow",
12245
- "btnBehind": "SD8qLW_btnBehind",
12246
- "stAdded": "SD8qLW_stAdded",
12247
- "line": "SD8qLW_line",
12248
- "gutter": "SD8qLW_gutter",
12249
- "funnelPreset": "SD8qLW_funnelPreset",
12250
- "treeFilterClear": "SD8qLW_treeFilterClear",
12251
- "funnelBoundKey": "SD8qLW_funnelBoundKey",
12252
- "commitActive": "SD8qLW_commitActive",
12253
- "code": "SD8qLW_code",
12254
- "commitFilter": "SD8qLW_commitFilter",
12287
+ "confirmTitle": "SD8qLW_confirmTitle",
12288
+ "commitStaged": "SD8qLW_commitStaged",
12289
+ "fileCounts": "SD8qLW_fileCounts",
12290
+ "file": "SD8qLW_file",
12291
+ "btnPrimary": "SD8qLW_btnPrimary",
12292
+ "refRowActive": "SD8qLW_refRowActive",
12255
12293
  "funnelPop": "SD8qLW_funnelPop",
12256
- "treeRow": "SD8qLW_treeRow",
12257
- "funnelList": "SD8qLW_funnelList",
12258
- "chevronOpen": "SD8qLW_chevronOpen",
12294
+ "tree": "SD8qLW_tree",
12295
+ "overlay": "SD8qLW_overlay",
12296
+ "commitRef": "SD8qLW_commitRef",
12297
+ "headerTotalsDim": "SD8qLW_headerTotalsDim",
12298
+ "treeWrap": "SD8qLW_treeWrap",
12299
+ "miniBtnPrimary": "SD8qLW_miniBtnPrimary",
12300
+ "elide": "SD8qLW_elide",
12301
+ "funnelTab": "SD8qLW_funnelTab",
12302
+ "checkBox": "SD8qLW_checkBox",
12303
+ "resizer": "SD8qLW_resizer",
12304
+ "tabActive": "SD8qLW_tabActive",
12305
+ "pathDirGlyph": "SD8qLW_pathDirGlyph",
12306
+ "cardWt": "SD8qLW_cardWt",
12307
+ "themeLabel": "SD8qLW_themeLabel",
12308
+ "headerRight": "SD8qLW_headerRight",
12309
+ "signAdd": "SD8qLW_signAdd",
12310
+ "scopeBtn": "SD8qLW_scopeBtn",
12311
+ "cardBranchName": "SD8qLW_cardBranchName",
12312
+ "wtCurrent": "SD8qLW_wtCurrent",
12313
+ "menuPop": "SD8qLW_menuPop",
12314
+ "miniBtn": "SD8qLW_miniBtn",
12315
+ "segmentActive": "SD8qLW_segmentActive",
12316
+ "stDeleted": "SD8qLW_stDeleted",
12317
+ "refButton": "SD8qLW_refButton",
12318
+ "calGrid": "SD8qLW_calGrid",
12319
+ "segmented": "SD8qLW_segmented",
12320
+ "filterChips": "SD8qLW_filterChips",
12321
+ "pathNode": "SD8qLW_pathNode",
12322
+ "treeActions": "SD8qLW_treeActions",
12323
+ "diffPre": "SD8qLW_diffPre",
12324
+ "lineContext": "SD8qLW_lineContext",
12325
+ "funnelRow": "SD8qLW_funnelRow",
12326
+ "commitWhen": "SD8qLW_commitWhen",
12327
+ "funnelButton": "SD8qLW_funnelButton",
12328
+ "btnBehind": "SD8qLW_btnBehind",
12329
+ "gsSlide": "SD8qLW_gsSlide",
12330
+ "stModified": "SD8qLW_stModified",
12331
+ "btn": "SD8qLW_btn",
12332
+ "chipSystem": "SD8qLW_chipSystem",
12333
+ "checkMarkOn": "SD8qLW_checkMarkOn",
12334
+ "funnel": "SD8qLW_funnel",
12335
+ "refPop": "SD8qLW_refPop",
12336
+ "treeIconOn": "SD8qLW_treeIconOn",
12337
+ "scopeBtnActive": "SD8qLW_scopeBtnActive",
12259
12338
  "segmentChip": "SD8qLW_segmentChip",
12260
- "refLabel": "SD8qLW_refLabel",
12261
- "calNav": "SD8qLW_calNav",
12339
+ "paneDividerActive": "SD8qLW_paneDividerActive",
12340
+ "commitPop": "SD8qLW_commitPop",
12341
+ "opBanner": "SD8qLW_opBanner",
12342
+ "funnelFootCountOn": "SD8qLW_funnelFootCountOn",
12343
+ "cardAdded": "SD8qLW_cardAdded",
12344
+ "drawer": "SD8qLW_drawer",
12345
+ "headerTotalsAdd": "SD8qLW_headerTotalsAdd",
12346
+ "refPicker": "SD8qLW_refPicker",
12262
12347
  "refFoot": "SD8qLW_refFoot",
12263
- "miniBtn": "SD8qLW_miniBtn",
12348
+ "treeTools": "SD8qLW_treeTools",
12264
12349
  "commitAmend": "SD8qLW_commitAmend",
12265
- "treeDirCounts": "SD8qLW_treeDirCounts",
12266
- "chevron": "SD8qLW_chevron",
12267
- "headerPicker": "SD8qLW_headerPicker",
12268
- "overlay": "SD8qLW_overlay",
12269
- "syncBar": "SD8qLW_syncBar",
12350
+ "pullGroup": "SD8qLW_pullGroup",
12351
+ "headerViewRef": "SD8qLW_headerViewRef",
12352
+ "refRow": "SD8qLW_refRow",
12353
+ "stRenamed": "SD8qLW_stRenamed",
12270
12354
  "funnelPresetActive": "SD8qLW_funnelPresetActive",
12271
- "refValue": "SD8qLW_refValue",
12272
- "refList": "SD8qLW_refList",
12273
- "funnelBoundRows": "SD8qLW_funnelBoundRows",
12274
- "funnelPresets": "SD8qLW_funnelPresets",
12275
- "funnelTabActive": "SD8qLW_funnelTabActive",
12276
- "headerPathMain": "SD8qLW_headerPathMain",
12355
+ "commitPopBody": "SD8qLW_commitPopBody",
12356
+ "renameLine": "SD8qLW_renameLine",
12357
+ "signDel": "SD8qLW_signDel",
12358
+ "funnelTabs": "SD8qLW_funnelTabs",
12359
+ "refGroup": "SD8qLW_refGroup",
12360
+ "commitsFoot": "SD8qLW_commitsFoot",
12361
+ "commitsPane": "SD8qLW_commitsPane",
12362
+ "funnelChevron": "SD8qLW_funnelChevron",
12363
+ "cssArea": "SD8qLW_cssArea",
12364
+ "refEmpty": "SD8qLW_refEmpty",
12365
+ "checkMark": "SD8qLW_checkMark",
12366
+ "themeGroup": "SD8qLW_themeGroup",
12367
+ "paneDivider": "SD8qLW_paneDivider",
12368
+ "elideHead": "SD8qLW_elideHead",
12369
+ "funnelFoot": "SD8qLW_funnelFoot",
12370
+ "fileCountDel": "SD8qLW_fileCountDel",
12371
+ "filePath": "SD8qLW_filePath",
12372
+ "cardDetached": "SD8qLW_cardDetached",
12373
+ "refLabel": "SD8qLW_refLabel",
12374
+ "stAdded": "SD8qLW_stAdded",
12375
+ "body": "SD8qLW_body",
12376
+ "filterChip": "SD8qLW_filterChip",
12377
+ "calNav": "SD8qLW_calNav",
12378
+ "calOut": "SD8qLW_calOut",
12379
+ "syncSpacer": "SD8qLW_syncSpacer",
12380
+ "treeDirLi": "SD8qLW_treeDirLi",
12381
+ "paneHead": "SD8qLW_paneHead",
12382
+ "chevronOpen": "SD8qLW_chevronOpen",
12383
+ "headerLeft": "SD8qLW_headerLeft",
12384
+ "scopeRow": "SD8qLW_scopeRow",
12385
+ "pathFileGlyph": "SD8qLW_pathFileGlyph",
12386
+ "treeEmpty": "SD8qLW_treeEmpty",
12387
+ "commitHasBody": "SD8qLW_commitHasBody",
12388
+ "lineDel": "SD8qLW_lineDel",
12389
+ "fileLi": "SD8qLW_fileLi",
12390
+ "segment": "SD8qLW_segment",
12391
+ "syncBar": "SD8qLW_syncBar",
12392
+ "treeLead": "SD8qLW_treeLead",
12393
+ "lineAdd": "SD8qLW_lineAdd",
12394
+ "line": "SD8qLW_line",
12395
+ "cardSep": "SD8qLW_cardSep",
12396
+ "gutter": "SD8qLW_gutter",
12397
+ "commitBox": "SD8qLW_commitBox",
12398
+ "calTitle": "SD8qLW_calTitle",
12399
+ "treeRow": "SD8qLW_treeRow",
12400
+ "paletteRowActive": "SD8qLW_paletteRowActive",
12401
+ "header": "SD8qLW_header",
12402
+ "commitMessage": "SD8qLW_commitMessage",
12277
12403
  "treeLabel": "SD8qLW_treeLabel",
12278
- "bgEmpty": "SD8qLW_bgEmpty",
12279
- "themeNote": "SD8qLW_themeNote",
12280
- "treeFilter": "SD8qLW_treeFilter",
12281
- "treeDirCount": "SD8qLW_treeDirCount",
12282
- "stModified": "SD8qLW_stModified",
12283
- "cardFiles": "SD8qLW_cardFiles",
12284
- "funnelButtonActive": "SD8qLW_funnelButtonActive",
12285
- "calWeek": "SD8qLW_calWeek",
12286
- "tabs": "SD8qLW_tabs",
12287
- "funnelFootCount": "SD8qLW_funnelFootCount",
12404
+ "lnOld": "SD8qLW_lnOld",
12405
+ "funnelBoundClear": "SD8qLW_funnelBoundClear",
12406
+ "headerPicker": "SD8qLW_headerPicker",
12288
12407
  "fileActive": "SD8qLW_fileActive",
12408
+ "chipDark": "SD8qLW_chipDark",
12409
+ "commitPopTop": "SD8qLW_commitPopTop",
12410
+ "treeDirCount": "SD8qLW_treeDirCount",
12411
+ "commitLead": "SD8qLW_commitLead",
12412
+ "paletteRow": "SD8qLW_paletteRow",
12289
12413
  "gsFade": "SD8qLW_gsFade",
12290
- "calIn": "SD8qLW_calIn",
12291
- "filePath": "SD8qLW_filePath",
12292
- "funnelBoundVal": "SD8qLW_funnelBoundVal",
12293
- "refRow": "SD8qLW_refRow",
12294
- "commitBox": "SD8qLW_commitBox",
12295
- "calMark": "SD8qLW_calMark",
12296
- "treeFilterInput": "SD8qLW_treeFilterInput",
12297
- "commit": "SD8qLW_commit",
12298
- "btnDanger": "SD8qLW_btnDanger",
12299
- "commitHasBody": "SD8qLW_commitHasBody",
12300
- "confirmBox": "SD8qLW_confirmBox",
12301
- "commitsFoot": "SD8qLW_commitsFoot",
12302
- "settingsPop": "SD8qLW_settingsPop",
12303
- "checkBox": "SD8qLW_checkBox",
12304
- "diffPre": "SD8qLW_diffPre",
12305
- "headerView": "SD8qLW_headerView",
12306
- "commitAuthor": "SD8qLW_commitAuthor",
12307
12414
  "filterClear": "SD8qLW_filterClear",
12308
- "treeTools": "SD8qLW_treeTools",
12309
- "scopeBtnActive": "SD8qLW_scopeBtnActive",
12310
- "chipLight": "SD8qLW_chipLight",
12311
- "fileCounts": "SD8qLW_fileCounts",
12312
- "refPop": "SD8qLW_refPop",
12313
- "themeGroup": "SD8qLW_themeGroup",
12314
- "funnelTabCount": "SD8qLW_funnelTabCount",
12315
- "cardBranchName": "SD8qLW_cardBranchName",
12316
- "card": "SD8qLW_card",
12317
- "commitWhen": "SD8qLW_commitWhen",
12318
- "cal": "SD8qLW_cal",
12319
- "calOut": "SD8qLW_calOut",
12415
+ "funnelTabActive": "SD8qLW_funnelTabActive",
12416
+ "opBannerBad": "SD8qLW_opBannerBad",
12417
+ "headerTotalsDel": "SD8qLW_headerTotalsDel",
12320
12418
  "refRowName": "SD8qLW_refRowName",
12321
- "diffPane": "SD8qLW_diffPane",
12322
- "headerDetached": "SD8qLW_headerDetached",
12323
- "drawer": "SD8qLW_drawer",
12324
- "refRowSpacer": "SD8qLW_refRowSpacer",
12325
- "calTitle": "SD8qLW_calTitle",
12326
- "wordAdd": "SD8qLW_wordAdd",
12327
- "cardDetached": "SD8qLW_cardDetached",
12328
- "funnelTabs": "SD8qLW_funnelTabs",
12329
- "funnelFoot": "SD8qLW_funnelFoot",
12330
- "cssArea": "SD8qLW_cssArea",
12331
- "gsSlide": "SD8qLW_gsSlide",
12419
+ "funnelName": "SD8qLW_funnelName",
12332
12420
  "elideTail": "SD8qLW_elideTail",
12333
- "treeCol": "SD8qLW_treeCol",
12334
- "fileCountAdd": "SD8qLW_fileCountAdd",
12335
- "stDeleted": "SD8qLW_stDeleted",
12336
- "commitRow": "SD8qLW_commitRow",
12421
+ "fileBinary": "SD8qLW_fileBinary",
12422
+ "treeIconDown": "SD8qLW_treeIconDown",
12337
12423
  "stUntracked": "SD8qLW_stUntracked",
12338
- "treeSub": "SD8qLW_treeSub",
12339
- "stRenamed": "SD8qLW_stRenamed",
12340
- "syncLevel": "SD8qLW_syncLevel",
12341
- "elideHead": "SD8qLW_elideHead",
12342
- "compareArrow": "SD8qLW_compareArrow",
12343
- "chipDark": "SD8qLW_chipDark",
12344
- "cardWt": "SD8qLW_cardWt",
12345
- "funnelBoundClear": "SD8qLW_funnelBoundClear",
12346
- "headerTotalsDim": "SD8qLW_headerTotalsDim",
12347
12424
  "confirmScrim": "SD8qLW_confirmScrim",
12348
- "funnelPane": "SD8qLW_funnelPane",
12349
- "tree": "SD8qLW_tree",
12350
- "treeIconGlyph": "SD8qLW_treeIconGlyph",
12351
- "btnCount": "SD8qLW_btnCount",
12425
+ "headerPathMain": "SD8qLW_headerPathMain",
12426
+ "theme": "SD8qLW_theme",
12427
+ "funnelBoundVal": "SD8qLW_funnelBoundVal",
12428
+ "sliderRow": "SD8qLW_sliderRow",
12429
+ "treeFilter": "SD8qLW_treeFilter",
12430
+ "commitFilter": "SD8qLW_commitFilter",
12431
+ "funnelBoundKey": "SD8qLW_funnelBoundKey",
12432
+ "graphCell": "SD8qLW_graphCell",
12433
+ "code": "SD8qLW_code",
12434
+ "opBannerOk": "SD8qLW_opBannerOk",
12435
+ "compareArrow": "SD8qLW_compareArrow",
12436
+ "treeSub": "SD8qLW_treeSub",
12437
+ "pathChildren": "SD8qLW_pathChildren",
12352
12438
  "commitHash": "SD8qLW_commitHash",
12353
- "headerViewRef": "SD8qLW_headerViewRef",
12354
- "funnelBoundValSet": "SD8qLW_funnelBoundValSet",
12355
- "confirmBody": "SD8qLW_confirmBody",
12439
+ "confirmBox": "SD8qLW_confirmBox",
12440
+ "treeFilterInput": "SD8qLW_treeFilterInput",
12441
+ "treeIcon": "SD8qLW_treeIcon",
12442
+ "calIn": "SD8qLW_calIn",
12443
+ "commitLine": "SD8qLW_commitLine",
12444
+ "chevron": "SD8qLW_chevron",
12445
+ "themeDirty": "SD8qLW_themeDirty",
12446
+ "refValue": "SD8qLW_refValue",
12447
+ "commits": "SD8qLW_commits",
12448
+ "funnelBounds": "SD8qLW_funnelBounds",
12449
+ "treeDir": "SD8qLW_treeDir",
12450
+ "headerBranch": "SD8qLW_headerBranch",
12451
+ "treeFilterClear": "SD8qLW_treeFilterClear",
12452
+ "commitTop": "SD8qLW_commitTop",
12356
12453
  "empty": "SD8qLW_empty",
12357
- "btnPrimary": "SD8qLW_btnPrimary",
12358
- "fileDiscard": "SD8qLW_fileDiscard",
12359
- "pathChildren": "SD8qLW_pathChildren",
12360
- "opBannerOk": "SD8qLW_opBannerOk",
12361
- "confirmTitle": "SD8qLW_confirmTitle",
12362
- "funnelCount": "SD8qLW_funnelCount",
12363
- "cardBranch": "SD8qLW_cardBranch",
12364
- "segmentActive": "SD8qLW_segmentActive",
12365
- "lineContext": "SD8qLW_lineContext",
12454
+ "swatch": "SD8qLW_swatch",
12455
+ "calMark": "SD8qLW_calMark",
12456
+ "commitPopSubject": "SD8qLW_commitPopSubject",
12457
+ "tab": "SD8qLW_tab",
12366
12458
  "treeDirName": "SD8qLW_treeDirName",
12367
- "commitPop": "SD8qLW_commitPop",
12459
+ "diffPane": "SD8qLW_diffPane",
12460
+ "commitActive": "SD8qLW_commitActive",
12461
+ "commitSubjectRow": "SD8qLW_commitSubjectRow",
12462
+ "refRowSpacer": "SD8qLW_refRowSpacer",
12463
+ "fileDiscard": "SD8qLW_fileDiscard",
12464
+ "checkMarkPartial": "SD8qLW_checkMarkPartial",
12465
+ "calToday": "SD8qLW_calToday",
12466
+ "btnClose": "SD8qLW_btnClose",
12368
12467
  "filterChipLabel": "SD8qLW_filterChipLabel",
12369
- "commitPopBody": "SD8qLW_commitPopBody",
12370
- "refRowActive": "SD8qLW_refRowActive",
12371
- "headerLeft": "SD8qLW_headerLeft",
12372
12468
  "headerTotals": "SD8qLW_headerTotals",
12373
- "resizer": "SD8qLW_resizer",
12374
- "funnel": "SD8qLW_funnel",
12375
- "funnelBoundRow": "SD8qLW_funnelBoundRow",
12376
- "refGroup": "SD8qLW_refGroup",
12377
- "opBanner": "SD8qLW_opBanner",
12378
- "miniBtnPrimary": "SD8qLW_miniBtnPrimary",
12379
- "btnIcon": "SD8qLW_btnIcon",
12380
- "funnelButton": "SD8qLW_funnelButton",
12381
- "funnelSearch": "SD8qLW_funnelSearch",
12382
- "refSearch": "SD8qLW_refSearch",
12383
- "commitsPane": "SD8qLW_commitsPane",
12384
- "lnOld": "SD8qLW_lnOld",
12385
- "treeIconOn": "SD8qLW_treeIconOn",
12386
- "headerBranch": "SD8qLW_headerBranch",
12387
- "swatch": "SD8qLW_swatch",
12388
- "funnelTab": "SD8qLW_funnelTab",
12389
- "syncUpstream": "SD8qLW_syncUpstream",
12390
- "commitLead": "SD8qLW_commitLead",
12391
- "pullGroup": "SD8qLW_pullGroup",
12392
- "lineAdd": "SD8qLW_lineAdd",
12393
- "treeIconDown": "SD8qLW_treeIconDown",
12394
- "checkMark": "SD8qLW_checkMark",
12395
- "commitStaged": "SD8qLW_commitStaged",
12396
- "lineHunk": "SD8qLW_lineHunk",
12397
- "treeWrap": "SD8qLW_treeWrap",
12398
- "overlayMax": "SD8qLW_overlayMax",
12399
- "menuPop": "SD8qLW_menuPop",
12400
- "tabActive": "SD8qLW_tabActive",
12401
- "btn": "SD8qLW_btn",
12402
- "headerTotalsDel": "SD8qLW_headerTotalsDel",
12403
- "lnNew": "SD8qLW_lnNew",
12404
- "paneTitle": "SD8qLW_paneTitle",
12405
- "refCaret": "SD8qLW_refCaret",
12406
- "renameLine": "SD8qLW_renameLine",
12407
- "scopeRow": "SD8qLW_scopeRow",
12408
- "paneDivider": "SD8qLW_paneDivider",
12409
- "refButton": "SD8qLW_refButton",
12469
+ "themeRail": "SD8qLW_themeRail",
12470
+ "treeCol": "SD8qLW_treeCol",
12410
12471
  "cardDeleted": "SD8qLW_cardDeleted",
12411
- "headerRight": "SD8qLW_headerRight",
12412
- "paletteRowActive": "SD8qLW_paletteRowActive",
12413
- "checkMarkPartial": "SD8qLW_checkMarkPartial",
12414
- "scopeBtn": "SD8qLW_scopeBtn",
12415
- "lineDel": "SD8qLW_lineDel",
12416
- "themeDirty": "SD8qLW_themeDirty",
12417
- "confirmActions": "SD8qLW_confirmActions",
12418
- "opBannerBad": "SD8qLW_opBannerBad",
12472
+ "treeIconGlyph": "SD8qLW_treeIconGlyph",
12473
+ "paneTitle": "SD8qLW_paneTitle",
12474
+ "settingsPop": "SD8qLW_settingsPop",
12419
12475
  "commitCopy": "SD8qLW_commitCopy",
12420
- "sliderRow": "SD8qLW_sliderRow",
12421
- "fileLi": "SD8qLW_fileLi",
12422
- "funnelCaption": "SD8qLW_funnelCaption",
12423
- "body": "SD8qLW_body",
12424
- "commitPopMeta": "SD8qLW_commitPopMeta",
12425
- "commitPopTop": "SD8qLW_commitPopTop",
12426
- "tab": "SD8qLW_tab",
12427
- "commitSubject": "SD8qLW_commitSubject",
12428
12476
  "commitsSentinel": "SD8qLW_commitsSentinel",
12429
- "cardGlyph": "SD8qLW_cardGlyph",
12430
- "paneDividerActive": "SD8qLW_paneDividerActive",
12431
- "funnelRow": "SD8qLW_funnelRow",
12432
- "commitRefMore": "SD8qLW_commitRefMore",
12433
- "themeLabel": "SD8qLW_themeLabel",
12434
- "commitBtn": "SD8qLW_commitBtn",
12435
- "refPicker": "SD8qLW_refPicker",
12436
- "treeLead": "SD8qLW_treeLead",
12437
- "funnelBoundBtn": "SD8qLW_funnelBoundBtn",
12438
- "treeDirLi": "SD8qLW_treeDirLi",
12439
- "fileStatus": "SD8qLW_fileStatus",
12440
- "btnClose": "SD8qLW_btnClose",
12441
- "funnelFootCountOn": "SD8qLW_funnelFootCountOn",
12442
- "paneHead": "SD8qLW_paneHead",
12443
- "funnelMore": "SD8qLW_funnelMore",
12444
- "headerTotalsAdd": "SD8qLW_headerTotalsAdd",
12445
- "fileCountDel": "SD8qLW_fileCountDel",
12446
- "bgPreview": "SD8qLW_bgPreview",
12477
+ "commitAuthor": "SD8qLW_commitAuthor",
12478
+ "wordAdd": "SD8qLW_wordAdd",
12479
+ "cardAhead": "SD8qLW_cardAhead",
12480
+ "funnelBoundRows": "SD8qLW_funnelBoundRows",
12481
+ "refSearch": "SD8qLW_refSearch",
12482
+ "headerDetached": "SD8qLW_headerDetached",
12483
+ "btnIcon": "SD8qLW_btnIcon",
12484
+ "funnelPane": "SD8qLW_funnelPane",
12485
+ "refList": "SD8qLW_refList",
12486
+ "funnelFootCount": "SD8qLW_funnelFootCount",
12487
+ "funnelCount": "SD8qLW_funnelCount",
12447
12488
  "cardBehind": "SD8qLW_cardBehind",
12448
- "header": "SD8qLW_header",
12489
+ "calWeek": "SD8qLW_calWeek",
12490
+ "funnelBoundBtn": "SD8qLW_funnelBoundBtn",
12491
+ "funnelCaption": "SD8qLW_funnelCaption",
12492
+ "funnelList": "SD8qLW_funnelList",
12493
+ "btnDanger": "SD8qLW_btnDanger",
12494
+ "fileCountAdd": "SD8qLW_fileCountAdd",
12449
12495
  "compareBar": "SD8qLW_compareBar",
12450
- "theme": "SD8qLW_theme",
12451
- "filterChips": "SD8qLW_filterChips",
12452
- "cardAhead": "SD8qLW_cardAhead",
12453
- "filterChip": "SD8qLW_filterChip",
12454
- "commitSubjectRow": "SD8qLW_commitSubjectRow",
12455
- "pathDirGlyph": "SD8qLW_pathDirGlyph",
12456
- "treeDir": "SD8qLW_treeDir",
12457
- "commitRef": "SD8qLW_commitRef",
12458
- "commitLine": "SD8qLW_commitLine",
12459
- "treeActions": "SD8qLW_treeActions",
12460
- "cardSep": "SD8qLW_cardSep",
12461
- "funnelFootClear": "SD8qLW_funnelFootClear",
12462
- "funnelName": "SD8qLW_funnelName",
12463
- "chipSystem": "SD8qLW_chipSystem",
12464
- "treeEmpty": "SD8qLW_treeEmpty",
12465
- "pathNode": "SD8qLW_pathNode",
12466
- "checkMarkOn": "SD8qLW_checkMarkOn",
12467
- "pathFileGlyph": "SD8qLW_pathFileGlyph",
12468
- "calToday": "SD8qLW_calToday",
12469
- "signAdd": "SD8qLW_signAdd",
12470
- "filterChipRemove": "SD8qLW_filterChipRemove",
12471
- "calHead": "SD8qLW_calHead",
12472
- "calGrid": "SD8qLW_calGrid"
12496
+ "syncLevel": "SD8qLW_syncLevel",
12497
+ "tabs": "SD8qLW_tabs",
12498
+ "resizerActive": "SD8qLW_resizerActive",
12499
+ "commitBtn": "SD8qLW_commitBtn",
12500
+ "funnelPresets": "SD8qLW_funnelPresets",
12501
+ "commitRow": "SD8qLW_commitRow",
12502
+ "funnelButtonActive": "SD8qLW_funnelButtonActive",
12503
+ "funnelPreset": "SD8qLW_funnelPreset",
12504
+ "funnelBoundValSet": "SD8qLW_funnelBoundValSet",
12505
+ "refCaret": "SD8qLW_refCaret",
12506
+ "funnelBoundRow": "SD8qLW_funnelBoundRow"
12473
12507
  };
12474
12508
  //#endregion
12475
12509
  //#region src/client/GitWorkbenchPanel.tsx
@@ -13229,6 +13263,25 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
13229
13263
  refresh();
13230
13264
  }
13231
13265
  };
13266
+ /**
13267
+ * Put a failure the drawer produced itself into the same banner git failures
13268
+ * use.
13269
+ *
13270
+ * Roll-back is the caller: it asks the host what a file's roll-back would do
13271
+ * before it does anything, and that question can fail on its own, with no
13272
+ * `runOp` behind it to report through. Everything else the drawer does is
13273
+ * either a git call or has a visible result of its own.
13274
+ */
13275
+ const reportOpError = (op, error) => {
13276
+ setOpResult({
13277
+ op,
13278
+ result: {
13279
+ ok: false,
13280
+ failure: "unknown",
13281
+ error
13282
+ }
13283
+ });
13284
+ };
13232
13285
  /** Wait for the git lock, so a queued tick batch waits out a heavy
13233
13286
  * operation instead of being refused by it. */
13234
13287
  const waitNotBusy = async () => {
@@ -13483,6 +13536,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
13483
13536
  opResult,
13484
13537
  runOp,
13485
13538
  fetchDiscardPlan,
13539
+ onOpError: reportOpError,
13486
13540
  pendingTicks,
13487
13541
  onTick: queueTicks,
13488
13542
  fetchFileDiff: fetchDiffForView,
@@ -13605,7 +13659,7 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
13605
13659
  ]
13606
13660
  });
13607
13661
  }
13608
- function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }) {
13662
+ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }) {
13609
13663
  const body = shown ?? EMPTY_STATS;
13610
13664
  /** The file list with ticks still awaiting git laid over them. The tree and
13611
13665
  * the commit box read this, so a click moves its box and the "N ticked"
@@ -13670,6 +13724,10 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
13670
13724
  * `recover` — a deleted file coming back — shows no dialog at all. It loses
13671
13725
  * nothing, and a confirmation in front of a pure gain is how people learn to
13672
13726
  * dismiss confirmations without reading them.
13727
+ *
13728
+ * Every other answer is `nextAfterPlan`'s to classify, and the one it exists
13729
+ * for is failure: a plan that never arrives reports, where it used to leave
13730
+ * the reader looking at a button that did nothing.
13673
13731
  */
13674
13732
  const askDiscard = (file) => {
13675
13733
  setDiscardPending({
@@ -13677,24 +13735,21 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
13677
13735
  plan: null
13678
13736
  });
13679
13737
  (async () => {
13680
- const preview = await fetchDiscardPlan(statsPath, file.path, new AbortController().signal);
13681
- if (preview === null || preview.effect === void 0) {
13682
- setDiscardPending(null);
13683
- onRefresh();
13684
- return;
13685
- }
13686
- if (preview.irreversible !== true) {
13687
- setDiscardPending(null);
13688
- runOp("discardFile", {
13689
- path: file.path,
13690
- expectedEffect: preview.effect
13738
+ const next = nextAfterPlan(await fetchDiscardPlan(statsPath, file.path, new AbortController().signal));
13739
+ if (next.kind === "confirm") {
13740
+ setDiscardPending({
13741
+ file,
13742
+ plan: next.plan
13691
13743
  });
13692
13744
  return;
13693
13745
  }
13694
- setDiscardPending({
13695
- file,
13696
- plan: preview
13746
+ setDiscardPending(null);
13747
+ if (next.kind === "run") runOp("discardFile", {
13748
+ path: file.path,
13749
+ expectedEffect: next.effect
13697
13750
  });
13751
+ else if (next.kind === "refresh") onRefresh();
13752
+ else onOpError("discardFile", next.error);
13698
13753
  })();
13699
13754
  };
13700
13755
  const confirmDiscard = () => {
@@ -17129,11 +17184,25 @@ XID_Start XIDS`.split(/\s/).map((p) => [w(p), p]));
17129
17184
  return result.ok ? result.value : null;
17130
17185
  },
17131
17186
  fetchDiscardPlan: async (worktreePath, path, signal) => {
17132
- const result = await connection.rpc.call("/api", "gitWorkbench/discardPlan", { args: {
17133
- worktreePath: worktreePath ?? "",
17134
- path
17135
- } }, signal);
17136
- return result.ok ? result.value : null;
17187
+ try {
17188
+ const result = await connection.rpc.call("/api", "gitWorkbench/discardPlan", { args: {
17189
+ worktreePath: worktreePath ?? "",
17190
+ path
17191
+ } }, signal);
17192
+ if (result.ok && result.value !== void 0) return {
17193
+ kind: "plan",
17194
+ plan: result.value
17195
+ };
17196
+ return {
17197
+ kind: "failed",
17198
+ error: result.error?.message ?? ""
17199
+ };
17200
+ } catch (error) {
17201
+ return {
17202
+ kind: "failed",
17203
+ error: error instanceof Error ? error.message : String(error)
17204
+ };
17205
+ }
17137
17206
  },
17138
17207
  runGitOp: async (op, worktreePath, payload, signal) => {
17139
17208
  const result = await connection.rpc.call("/api", `gitWorkbench/${op}`, { args: {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The one filesystem delete in this plugin, and the checks it carries.
3
+ *
4
+ * `discard-ops.ts` plans a delete when git has no copy of a file to restore
5
+ * from — untracked, or added-but-never-committed. git will not carry that out:
6
+ * `git clean` refuses paths it cannot index, which on Windows includes every
7
+ * reserved device name (`nul`, `con`, `aux`, `com1`, and the same names with
8
+ * any extension). So the removal goes through the filesystem, where git's own
9
+ * refusal to leave the repository does not apply — hence the checks here
10
+ * rather than a bare `rm`.
11
+ *
12
+ * Lives outside `index.ts` so vitest can load it: the class there needs the
13
+ * dsh runtime, and the property worth testing is "what does this delete, and
14
+ * what does it refuse" — a question about paths and the disk, not about RPC.
15
+ *
16
+ * @module @young1lin/dsh-ui-gitworkbench/fs-remove
17
+ */
18
+ import { rm } from 'node:fs/promises';
19
+ import { resolve, sep } from 'node:path';
20
+ import { isSafeRelativePath } from './discard-ops.js';
21
+ /**
22
+ * Resolve a repo-relative path against the worktree root, refusing to leave it.
23
+ *
24
+ * The second lock rather than the only one: {@link isSafeRelativePath} already
25
+ * rejected traversal spellings when the plan was made. This re-checks the
26
+ * RESOLVED path, which is the form the filesystem acts on, so a path that
27
+ * survives the first check by being spelled unusually still has to land inside
28
+ * the root to be acted on.
29
+ *
30
+ * @param root - the worktree directory, absolute.
31
+ * @param relative - repo-relative path from a plan step.
32
+ * @returns the absolute path to act on.
33
+ * @throws if the path is not a safe relative path, resolves outside the root,
34
+ * or IS the root.
35
+ */
36
+ export function resolveInside(root, relative) {
37
+ if (!isSafeRelativePath(relative)) {
38
+ throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`);
39
+ }
40
+ const base = resolve(root);
41
+ const target = resolve(base, relative);
42
+ if (target === base)
43
+ throw new Error('refusing to delete the worktree root');
44
+ if (!target.startsWith(base + sep)) {
45
+ throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`);
46
+ }
47
+ return target;
48
+ }
49
+ /**
50
+ * Remove one entry from the worktree, having proven it is inside it.
51
+ *
52
+ * `recursive` is not a widening of the blast radius: `resolveInside` has
53
+ * already pinned the target to one path git named, and git names a DIRECTORY
54
+ * whenever it will not look inside one — an untracked nested repository is
55
+ * reported as `sub/`, with no per-file lines even under
56
+ * `--untracked-files=all`. Without `recursive` that row is the only one in the
57
+ * drawer whose roll-back fails, and it fails as `EISDIR`, which says nothing
58
+ * to the person who clicked it.
59
+ *
60
+ * `force` makes an absent entry a success: the reader asked for it to be gone,
61
+ * and it is.
62
+ *
63
+ * A symlinked directory inside the worktree could still point outward; that is
64
+ * a repository someone already has write access to, and resolving link targets
65
+ * per segment on every delete would cost a stat per segment for a case git
66
+ * itself does not defend against.
67
+ *
68
+ * @param root - the worktree directory, absolute.
69
+ * @param relative - repo-relative path from a plan step.
70
+ */
71
+ export async function removePathInside(root, relative) {
72
+ await rm(resolveInside(root, relative), { recursive: true, force: true });
73
+ }
package/lib/index.js CHANGED
@@ -76,15 +76,16 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
76
76
  * @module @young1lin/dsh-ui-gitworkbench
77
77
  */
78
78
  import { randomBytes } from 'node:crypto';
79
- import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
79
+ import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises';
80
80
  import { homedir } from 'node:os';
81
- import { join, resolve, sep } from 'node:path';
81
+ import { join } from 'node:path';
82
82
  import { defineTool } from '@deepseek-ai/dsh-tools';
83
83
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
84
84
  import { saveJsonAtomic } from './atomic-json.js';
85
85
  import { CommitPayloadCache, cacheKey } from './commit-cache.js';
86
86
  import { NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff, commitArgv, countBufferLines, fetchArgv, isBinaryPrefix, isNoMergeBaseError, isSafePathArg, parseNameStatus, parseNumstat, parseStatus, parseTracking, pullArgv, pushArgv, stageArgv, unstageArgv, } from './git-ops.js';
87
87
  import { planFromStatus, } from './discard-ops.js';
88
+ import { removePathInside } from './fs-remove.js';
88
89
  import { LOG_FORMAT, parseLog } from './git-log.js';
89
90
  import { emptyLogFilter, logFilterArgs } from './log-filter.js';
90
91
  import { parseShortlog } from './shortlog.js';
@@ -1034,7 +1035,7 @@ let GitWorkbenchService = (() => {
1034
1035
  continue;
1035
1036
  }
1036
1037
  try {
1037
- await this.removeInside(cwd, step.path);
1038
+ await removePathInside(cwd, step.path);
1038
1039
  }
1039
1040
  catch (error) {
1040
1041
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
@@ -1061,29 +1062,6 @@ let GitWorkbenchService = (() => {
1061
1062
  }
1062
1063
  return planFromStatus(status.stdout, path);
1063
1064
  }
1064
- /**
1065
- * Delete one file, having proven it is inside the worktree.
1066
- *
1067
- * `isSafeRelativePath` already rejected traversal in the plan, so this is the
1068
- * second lock rather than the only one: it re-checks the RESOLVED path,
1069
- * which is the form the filesystem actually acts on. `force` makes an absent
1070
- * file a success — the reader asked for it to be gone, and it is.
1071
- *
1072
- * A symlinked directory inside the worktree could still point outward; that
1073
- * is a repository someone already has write access to, and resolving link
1074
- * targets on every delete would cost a stat per segment for a case git
1075
- * itself does not defend against.
1076
- */
1077
- async removeInside(cwd, relative) {
1078
- const root = resolve(cwd);
1079
- const target = resolve(root, relative);
1080
- if (target !== root && !target.startsWith(root + sep)) {
1081
- throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`);
1082
- }
1083
- if (target === root)
1084
- throw new Error('refusing to delete the worktree root');
1085
- await rm(target, { force: true });
1086
- }
1087
1065
  /**
1088
1066
  * Commit what is in the index.
1089
1067
  * @param worktreePath - directory to run in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@young1lin/dsh-ui-gitworkbench",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -60,6 +60,7 @@ import { layoutGraph, type GraphRow } from './commit-graph.ts'
60
60
  import { formatCommitDate } from './commit-filter.ts'
61
61
  import { chipsFromFilter, emptyQueryFilter, parseLogQuery, removeChip, serializeLogQuery } from './log-filter-query.ts'
62
62
  import { buildDirTree, searchPaths, type DirEntry } from './dir-tree.ts'
63
+ import { nextAfterPlan, type DiscardAnswer, type DiscardPreview } from './discard-flow.ts'
63
64
  import { filterFiles } from './file-filter.ts'
64
65
  import { addPath, buildIndex, checkedState, isCovered, removePath } from './path-select.ts'
65
66
  import { inCalRange, localTodayIso, monthGrid, weekdayLabels } from './calendar.ts'
@@ -216,14 +217,7 @@ export interface GitOpPayload {
216
217
  readonly expectedEffect?: string
217
218
  }
218
219
 
219
- /** What the host says rolling one file back would do. */
220
- export interface DiscardPreview {
221
- /** Absent when git reports nothing to roll back for that path. */
222
- readonly effect?: 'restore' | 'delete' | 'recover' | 'unrename'
223
- readonly irreversible?: boolean
224
- readonly previousPath?: string
225
- readonly error?: string
226
- }
220
+ export type { DiscardAnswer, DiscardNext, DiscardPreview } from './discard-flow.ts'
227
221
 
228
222
  /** Translate a key of this plugin's namespace, with optional `{name}` params. */
229
223
  type Translate = (key: string, params?: Record<string, string | number>) => string
@@ -249,7 +243,7 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
249
243
  readonly runGitOp: (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal) => Promise<GitOpResult>
250
244
  /** What rolling this file back WOULD do, read fresh so the confirmation
251
245
  * states the real consequence rather than one derived from a polled row. */
252
- readonly fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardPreview | null>
246
+ readonly fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardAnswer>
253
247
  }
254
248
 
255
249
  /**
@@ -1032,6 +1026,19 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1032
1026
  }
1033
1027
  }
1034
1028
 
1029
+ /**
1030
+ * Put a failure the drawer produced itself into the same banner git failures
1031
+ * use.
1032
+ *
1033
+ * Roll-back is the caller: it asks the host what a file's roll-back would do
1034
+ * before it does anything, and that question can fail on its own, with no
1035
+ * `runOp` behind it to report through. Everything else the drawer does is
1036
+ * either a git call or has a visible result of its own.
1037
+ */
1038
+ const reportOpError = (op: GitOpName, error: string): void => {
1039
+ setOpResult({ op, result: { ok: false, failure: 'unknown', error } })
1040
+ }
1041
+
1035
1042
  /** Wait for the git lock, so a queued tick batch waits out a heavy
1036
1043
  * operation instead of being refused by it. */
1037
1044
  const waitNotBusy = async (): Promise<void> => {
@@ -1304,6 +1311,7 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1304
1311
  opResult={opResult}
1305
1312
  runOp={runOp}
1306
1313
  fetchDiscardPlan={fetchDiscardPlan}
1314
+ onOpError={reportOpError}
1307
1315
  pendingTicks={pendingTicks}
1308
1316
  onTick={queueTicks}
1309
1317
  fetchFileDiff={fetchDiffForView}
@@ -1495,7 +1503,9 @@ interface DrawerProps {
1495
1503
  /** The last write operation's outcome, or null once a new one starts. */
1496
1504
  opResult: { op: GitOpName; result: GitOpResult } | null
1497
1505
  runOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
1498
- fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardPreview | null>
1506
+ fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardAnswer>
1507
+ /** Say why an operation the drawer started did nothing. */
1508
+ onOpError: (op: GitOpName, error: string) => void
1499
1509
  /** Ticks awaiting their git call, keyed by path — overlaid over the file
1500
1510
  * list so the click is on screen before git confirms it. */
1501
1511
  pendingTicks: ReadonlyMap<string, TickAction>
@@ -1509,7 +1519,7 @@ interface DrawerProps {
1509
1519
  onCollapsedChange: (next: Set<string>) => void
1510
1520
  }
1511
1521
 
1512
- function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }: DrawerProps): ReactNode {
1522
+ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }: DrawerProps): ReactNode {
1513
1523
  // Empty stand-in while a commit's change set loads, so every hook below keeps a
1514
1524
  // stable shape and the panes simply render nothing.
1515
1525
  const body = shown ?? EMPTY_STATS
@@ -1581,27 +1591,23 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1581
1591
  * `recover` — a deleted file coming back — shows no dialog at all. It loses
1582
1592
  * nothing, and a confirmation in front of a pure gain is how people learn to
1583
1593
  * dismiss confirmations without reading them.
1594
+ *
1595
+ * Every other answer is `nextAfterPlan`'s to classify, and the one it exists
1596
+ * for is failure: a plan that never arrives reports, where it used to leave
1597
+ * the reader looking at a button that did nothing.
1584
1598
  */
1585
1599
  const askDiscard = (file: GitFile): void => {
1586
1600
  setDiscardPending({ file, plan: null })
1587
1601
  void (async () => {
1588
- const preview = await fetchDiscardPlan(statsPath, file.path, new AbortController().signal)
1589
- // Nothing to roll back means the row was stale — the tree lists only
1590
- // changed files, so a file with no change is one git has since seen
1591
- // settled. Refreshing is the honest answer: the row goes away, which is
1592
- // both the feedback and the fix. A banner saying "nothing happened"
1593
- // would leave the row that caused it sitting right there.
1594
- if (preview === null || preview.effect === undefined) {
1595
- setDiscardPending(null)
1596
- onRefresh()
1597
- return
1598
- }
1599
- if (preview.irreversible !== true) {
1600
- setDiscardPending(null)
1601
- void runOp('discardFile', { path: file.path, expectedEffect: preview.effect })
1602
+ const next = nextAfterPlan(await fetchDiscardPlan(statsPath, file.path, new AbortController().signal))
1603
+ if (next.kind === 'confirm') {
1604
+ setDiscardPending({ file, plan: next.plan })
1602
1605
  return
1603
1606
  }
1604
- setDiscardPending({ file, plan: preview })
1607
+ setDiscardPending(null)
1608
+ if (next.kind === 'run') void runOp('discardFile', { path: file.path, expectedEffect: next.effect })
1609
+ else if (next.kind === 'refresh') onRefresh()
1610
+ else onOpError('discardFile', next.error)
1605
1611
  })()
1606
1612
  }
1607
1613
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * What a roll-back click does with the host's answer.
3
+ *
4
+ * The click never acts on its own: it asks the host what rolling this file
5
+ * back WOULD do, and this decides what the answer means. Four answers are
6
+ * possible and three of them used to collapse into one — the reader who
7
+ * clicked saw the same nothing whether the file was already clean, the host
8
+ * threw, or the request never arrived. "Nothing visibly happened" is the one
9
+ * outcome a destructive control must never produce ambiguously: it is
10
+ * indistinguishable from a dead button, and the natural response to a dead
11
+ * button is to click it again.
12
+ *
13
+ * So a failure REPORTS and a stale row REFRESHES, and those are different
14
+ * things. Refreshing is the honest answer to "git says this file has no
15
+ * changes": the row disappears, which is both the feedback and the fix, and a
16
+ * banner reading "nothing happened" would leave the row that caused it sitting
17
+ * right there. A failure has no such self-explaining fix, so it has to be said.
18
+ *
19
+ * Pure so vitest can load it — the panel it serves pulls React and a CSS
20
+ * module. The panel imports {@link DiscardPreview} from here for the same
21
+ * reason: a decision about the shape cannot be tested where the shape lives.
22
+ *
23
+ * @module @young1lin/dsh-ui-gitworkbench/client/discard-flow
24
+ */
25
+
26
+ /** What the host says rolling one file back would do. */
27
+ export interface DiscardPreview {
28
+ /** Absent when git reports nothing to roll back for that path. */
29
+ readonly effect?: 'restore' | 'delete' | 'recover' | 'unrename'
30
+ readonly irreversible?: boolean
31
+ readonly previousPath?: string
32
+ readonly error?: string
33
+ }
34
+
35
+ /** The host's reply to `discardPlan`, failure included. */
36
+ export type DiscardAnswer =
37
+ /** The call returned; the plan may still be empty. */
38
+ | { readonly kind: 'plan'; readonly plan: DiscardPreview }
39
+ /** The call did not return a plan — it errored, threw, or was refused. */
40
+ | { readonly kind: 'failed'; readonly error: string }
41
+
42
+ /** What the drawer does next. */
43
+ export type DiscardNext =
44
+ /** Open the confirmation naming this plan's consequence. */
45
+ | { readonly kind: 'confirm'; readonly plan: DiscardPreview }
46
+ /** Carry it out with no dialog — nothing is lost. */
47
+ | { readonly kind: 'run'; readonly effect: string }
48
+ /** The row was stale; reload the tree and let it go away. */
49
+ | { readonly kind: 'refresh' }
50
+ /** Say why nothing was done. */
51
+ | { readonly kind: 'report'; readonly error: string }
52
+
53
+ /** Fallback text for a failure that arrived with nothing to say. */
54
+ export const UNKNOWN_DISCARD_ERROR = 'discardPlan failed'
55
+
56
+ /**
57
+ * Decide what a roll-back click does with the answer it got.
58
+ *
59
+ * @param answer - the host's reply, or the failure that replaced it.
60
+ * @returns the single next step; never null, because every answer including a
61
+ * broken one has to lead somewhere the reader can see.
62
+ */
63
+ export function nextAfterPlan(answer: DiscardAnswer): DiscardNext {
64
+ if (answer.kind === 'failed') {
65
+ const error = answer.error.trim()
66
+ return { kind: 'report', error: error.length > 0 ? error : UNKNOWN_DISCARD_ERROR }
67
+ }
68
+ const plan = answer.plan
69
+ // The host reports a refusal in-band too, so a plan carrying an error is a
70
+ // failure that happened to arrive over a successful call.
71
+ if (typeof plan.error === 'string' && plan.error.trim().length > 0) {
72
+ return { kind: 'report', error: plan.error.trim() }
73
+ }
74
+ if (plan.effect === undefined) return { kind: 'refresh' }
75
+ // `recover` — a deleted file coming back — loses nothing, and a confirmation
76
+ // in front of a pure gain is how people learn to dismiss confirmations
77
+ // without reading them. Only an EXPLICIT `false` skips the dialog: a host
78
+ // newer than this bundle can name an effect this client has no copy for, and
79
+ // a missing flag read as "reversible" would act on it silently.
80
+ if (plan.irreversible === false) return { kind: 'run', effect: plan.effect }
81
+ return { kind: 'confirm', plan }
82
+ }
@@ -19,7 +19,7 @@ import type {} from '@deepseek-ai/dsh-client-runtime' // informational inject ed
19
19
  import type {} from '@deepseek-ai/dsh-client-ui-slots' // SlotMap is reused, not extended
20
20
  import {
21
21
  GitWorkbenchPanel,
22
- type DiscardPreview, type GitCommit, type GitOpName, type GitOpPayload, type GitOpResult,
22
+ type DiscardAnswer, type DiscardPreview, type GitCommit, type GitOpName, type GitOpPayload, type GitOpResult,
23
23
  type WorkbenchStats, type SyncStatus, type WorktreeStatus,
24
24
  } from './GitWorkbenchPanel.tsx'
25
25
  import type { StyleEntry, StyleScope, StyleSettings } from './themes.ts'
@@ -211,14 +211,22 @@ export function apply(ctx: ClientContext): void {
211
211
  // is the difference between "goes back to its committed content" and
212
212
  // "leaves the disk and cannot come back" — which is the entire question
213
213
  // the dialog exists to ask.
214
- fetchDiscardPlan: async (worktreePath: string | undefined, path: string, signal: AbortSignal): Promise<DiscardPreview | null> => {
215
- const result = await connection.rpc.call(
216
- '/api',
217
- 'gitWorkbench/discardPlan',
218
- { args: { worktreePath: worktreePath ?? '', path } },
219
- signal,
220
- ) as { ok: true; value: DiscardPreview } | { ok: false; error: { message?: string } }
221
- return result.ok ? result.value : null
214
+ fetchDiscardPlan: async (worktreePath: string | undefined, path: string, signal: AbortSignal): Promise<DiscardAnswer> => {
215
+ // A throw here used to be nobody's: the click had already put the
216
+ // drawer into "asking the host", and an unhandled rejection left it
217
+ // there with no dialog and no way back except closing the drawer.
218
+ try {
219
+ const result = await connection.rpc.call(
220
+ '/api',
221
+ 'gitWorkbench/discardPlan',
222
+ { args: { worktreePath: worktreePath ?? '', path } },
223
+ signal,
224
+ ) as { ok: boolean; value?: DiscardPreview; error?: { message?: string } }
225
+ if (result.ok && result.value !== undefined) return { kind: 'plan', plan: result.value }
226
+ return { kind: 'failed', error: result.error?.message ?? '' }
227
+ } catch (error) {
228
+ return { kind: 'failed', error: error instanceof Error ? error.message : String(error) }
229
+ }
222
230
  },
223
231
  runGitOp: async (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal): Promise<GitOpResult> => {
224
232
  const result = await connection.rpc.call(
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The one filesystem delete in this plugin, and the checks it carries.
3
+ *
4
+ * `discard-ops.ts` plans a delete when git has no copy of a file to restore
5
+ * from — untracked, or added-but-never-committed. git will not carry that out:
6
+ * `git clean` refuses paths it cannot index, which on Windows includes every
7
+ * reserved device name (`nul`, `con`, `aux`, `com1`, and the same names with
8
+ * any extension). So the removal goes through the filesystem, where git's own
9
+ * refusal to leave the repository does not apply — hence the checks here
10
+ * rather than a bare `rm`.
11
+ *
12
+ * Lives outside `index.ts` so vitest can load it: the class there needs the
13
+ * dsh runtime, and the property worth testing is "what does this delete, and
14
+ * what does it refuse" — a question about paths and the disk, not about RPC.
15
+ *
16
+ * @module @young1lin/dsh-ui-gitworkbench/fs-remove
17
+ */
18
+
19
+ import { rm } from 'node:fs/promises'
20
+ import { resolve, sep } from 'node:path'
21
+
22
+ import { isSafeRelativePath } from './discard-ops.js'
23
+
24
+ /**
25
+ * Resolve a repo-relative path against the worktree root, refusing to leave it.
26
+ *
27
+ * The second lock rather than the only one: {@link isSafeRelativePath} already
28
+ * rejected traversal spellings when the plan was made. This re-checks the
29
+ * RESOLVED path, which is the form the filesystem acts on, so a path that
30
+ * survives the first check by being spelled unusually still has to land inside
31
+ * the root to be acted on.
32
+ *
33
+ * @param root - the worktree directory, absolute.
34
+ * @param relative - repo-relative path from a plan step.
35
+ * @returns the absolute path to act on.
36
+ * @throws if the path is not a safe relative path, resolves outside the root,
37
+ * or IS the root.
38
+ */
39
+ export function resolveInside(root: string, relative: string): string {
40
+ if (!isSafeRelativePath(relative)) {
41
+ throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`)
42
+ }
43
+ const base = resolve(root)
44
+ const target = resolve(base, relative)
45
+ if (target === base) throw new Error('refusing to delete the worktree root')
46
+ if (!target.startsWith(base + sep)) {
47
+ throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`)
48
+ }
49
+ return target
50
+ }
51
+
52
+ /**
53
+ * Remove one entry from the worktree, having proven it is inside it.
54
+ *
55
+ * `recursive` is not a widening of the blast radius: `resolveInside` has
56
+ * already pinned the target to one path git named, and git names a DIRECTORY
57
+ * whenever it will not look inside one — an untracked nested repository is
58
+ * reported as `sub/`, with no per-file lines even under
59
+ * `--untracked-files=all`. Without `recursive` that row is the only one in the
60
+ * drawer whose roll-back fails, and it fails as `EISDIR`, which says nothing
61
+ * to the person who clicked it.
62
+ *
63
+ * `force` makes an absent entry a success: the reader asked for it to be gone,
64
+ * and it is.
65
+ *
66
+ * A symlinked directory inside the worktree could still point outward; that is
67
+ * a repository someone already has write access to, and resolving link targets
68
+ * per segment on every delete would cost a stat per segment for a case git
69
+ * itself does not defend against.
70
+ *
71
+ * @param root - the worktree directory, absolute.
72
+ * @param relative - repo-relative path from a plan step.
73
+ */
74
+ export async function removePathInside(root: string, relative: string): Promise<void> {
75
+ await rm(resolveInside(root, relative), { recursive: true, force: true })
76
+ }
package/src/index.ts CHANGED
@@ -42,9 +42,9 @@
42
42
  * @module @young1lin/dsh-ui-gitworkbench
43
43
  */
44
44
  import { randomBytes } from 'node:crypto'
45
- import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
45
+ import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'
46
46
  import { homedir } from 'node:os'
47
- import { join, resolve, sep } from 'node:path'
47
+ import { join } from 'node:path'
48
48
  import type { Readable } from 'node:stream'
49
49
  import type { Context } from '@deepseek-ai/cordis'
50
50
  import { defineTool, type ToolRunContext } from '@deepseek-ai/dsh-tools'
@@ -63,6 +63,7 @@ import {
63
63
  planFromStatus,
64
64
  type DiscardEffect, type DiscardPlan,
65
65
  } from './discard-ops.js'
66
+ import { removePathInside } from './fs-remove.js'
66
67
  import { LOG_FORMAT, parseLog, type GitCommit } from './git-log.js'
67
68
  import { emptyLogFilter, logFilterArgs, type LogFilter } from './log-filter.js'
68
69
  import { parseShortlog, type AuthorEntry } from './shortlog.js'
@@ -1040,7 +1041,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1040
1041
  continue
1041
1042
  }
1042
1043
  try {
1043
- await this.removeInside(cwd, step.path)
1044
+ await removePathInside(cwd, step.path)
1044
1045
  } catch (error) {
1045
1046
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1046
1047
  }
@@ -1068,29 +1069,6 @@ export class GitWorkbenchService extends TypertRemoteService {
1068
1069
  return planFromStatus(status.stdout, path)
1069
1070
  }
1070
1071
 
1071
- /**
1072
- * Delete one file, having proven it is inside the worktree.
1073
- *
1074
- * `isSafeRelativePath` already rejected traversal in the plan, so this is the
1075
- * second lock rather than the only one: it re-checks the RESOLVED path,
1076
- * which is the form the filesystem actually acts on. `force` makes an absent
1077
- * file a success — the reader asked for it to be gone, and it is.
1078
- *
1079
- * A symlinked directory inside the worktree could still point outward; that
1080
- * is a repository someone already has write access to, and resolving link
1081
- * targets on every delete would cost a stat per segment for a case git
1082
- * itself does not defend against.
1083
- */
1084
- private async removeInside(cwd: string, relative: string): Promise<void> {
1085
- const root = resolve(cwd)
1086
- const target = resolve(root, relative)
1087
- if (target !== root && !target.startsWith(root + sep)) {
1088
- throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`)
1089
- }
1090
- if (target === root) throw new Error('refusing to delete the worktree root')
1091
- await rm(target, { force: true })
1092
- }
1093
-
1094
1072
  /**
1095
1073
  * Commit what is in the index.
1096
1074
  * @param worktreePath - directory to run in.