@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 +9 -0
- package/CHANGELOG_EN.md +9 -0
- package/lib/client.js +316 -247
- package/lib/fs-remove.js +73 -0
- package/lib/index.js +4 -26
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.tsx +32 -26
- package/src/client/discard-flow.ts +82 -0
- package/src/client/index.ts +17 -9
- package/src/fs-remove.ts +76 -0
- package/src/index.ts +4 -26
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
|
-
"
|
|
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
|
-
"
|
|
12222
|
-
"
|
|
12223
|
-
"
|
|
12224
|
-
"
|
|
12225
|
-
"
|
|
12226
|
-
"
|
|
12227
|
-
"
|
|
12228
|
-
"
|
|
12229
|
-
"
|
|
12230
|
-
"
|
|
12231
|
-
"
|
|
12232
|
-
"
|
|
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
|
-
"
|
|
12241
|
-
"
|
|
12242
|
-
"
|
|
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
|
-
"
|
|
12245
|
-
"
|
|
12246
|
-
"
|
|
12247
|
-
"
|
|
12248
|
-
"
|
|
12249
|
-
"
|
|
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
|
-
"
|
|
12257
|
-
"
|
|
12258
|
-
"
|
|
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
|
-
"
|
|
12261
|
-
"
|
|
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
|
-
"
|
|
12348
|
+
"treeTools": "SD8qLW_treeTools",
|
|
12264
12349
|
"commitAmend": "SD8qLW_commitAmend",
|
|
12265
|
-
"
|
|
12266
|
-
"
|
|
12267
|
-
"
|
|
12268
|
-
"
|
|
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
|
-
"
|
|
12272
|
-
"
|
|
12273
|
-
"
|
|
12274
|
-
"
|
|
12275
|
-
"
|
|
12276
|
-
"
|
|
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
|
-
"
|
|
12279
|
-
"
|
|
12280
|
-
"
|
|
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
|
-
"
|
|
12309
|
-
"
|
|
12310
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
12334
|
-
"
|
|
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
|
-
"
|
|
12349
|
-
"
|
|
12350
|
-
"
|
|
12351
|
-
"
|
|
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
|
-
"
|
|
12354
|
-
"
|
|
12355
|
-
"
|
|
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
|
-
"
|
|
12358
|
-
"
|
|
12359
|
-
"
|
|
12360
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
12374
|
-
"
|
|
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
|
-
"
|
|
12412
|
-
"
|
|
12413
|
-
"
|
|
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
|
-
"
|
|
12430
|
-
"
|
|
12431
|
-
"
|
|
12432
|
-
"
|
|
12433
|
-
"
|
|
12434
|
-
"
|
|
12435
|
-
"
|
|
12436
|
-
"
|
|
12437
|
-
"
|
|
12438
|
-
"
|
|
12439
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
12451
|
-
"
|
|
12452
|
-
"
|
|
12453
|
-
"
|
|
12454
|
-
"
|
|
12455
|
-
"
|
|
12456
|
-
"
|
|
12457
|
-
"
|
|
12458
|
-
"
|
|
12459
|
-
"
|
|
12460
|
-
"
|
|
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
|
|
13681
|
-
if (
|
|
13682
|
-
setDiscardPending(
|
|
13683
|
-
|
|
13684
|
-
|
|
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
|
-
|
|
13696
|
-
|
|
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
|
-
|
|
17133
|
-
|
|
17134
|
-
|
|
17135
|
-
|
|
17136
|
-
|
|
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: {
|
package/lib/fs-remove.js
ADDED
|
@@ -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,
|
|
79
|
+
import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises';
|
|
80
80
|
import { homedir } from 'node:os';
|
|
81
|
-
import { join
|
|
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
|
|
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.
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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
|
|
1589
|
-
|
|
1590
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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(
|
package/src/fs-remove.ts
ADDED
|
@@ -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,
|
|
45
|
+
import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'
|
|
46
46
|
import { homedir } from 'node:os'
|
|
47
|
-
import { join
|
|
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
|
|
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.
|