zen-gitsync 2.15.10 → 2.15.12

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.
Files changed (77) hide show
  1. package/package.json +4 -3
  2. package/src/gitCommit.js +475 -475
  3. package/src/ui/public/assets/AppVersionBadge-BaXEI9Aq.js +2 -0
  4. package/src/ui/public/assets/BranchSelector-DJhEF0y9.js +1 -0
  5. package/src/ui/public/assets/CommitForm-BfpcK-bz.js +6 -0
  6. package/src/ui/public/assets/ConsoleView-D838O0O6.js +17 -0
  7. package/src/ui/public/assets/CustomCommandManager-BcCK46df.js +1 -0
  8. package/src/ui/public/assets/EditorView-DnnqYNGF.js +0 -0
  9. package/src/ui/public/assets/{FlowExecutionViewer-Dvv2-pUp.js → FlowExecutionViewer-CSlMmcDS.js} +1 -1
  10. package/src/ui/public/assets/FlowOrchestrationWorkspace-UgHpZ3FR.js +21 -0
  11. package/src/ui/public/assets/LogList-uUgskwwp.js +7 -0
  12. package/src/ui/public/assets/MindmapView-CCmFMiHR.js +1 -0
  13. package/src/ui/public/assets/{MindmapView-Bt2VdyoX.css → MindmapView-KFcJx-i-.css} +1 -1
  14. package/src/ui/public/assets/MonitorView-BxoqeX70.js +1 -0
  15. package/src/ui/public/assets/ProjectStartupButton-C3AwivIB.js +1 -0
  16. package/src/ui/public/assets/{RemoteRepoCard-BbKpAzPj.js → RemoteRepoCard-BbxeVpje.js} +1 -1
  17. package/src/ui/public/assets/SourceMapView-DKv-Y6t_.js +3 -0
  18. package/src/ui/public/assets/{SvgIcon-BoP8CWdO.js → SvgIcon-sMM8XFgm.js} +1 -1
  19. package/src/ui/public/assets/TemplateManager-Dzj8rXVw.js +1 -0
  20. package/src/ui/public/assets/UserInputNode-BU7Trzq-.js +1 -0
  21. package/src/ui/public/assets/WorkbenchView-DMoulJCG.css +1 -0
  22. package/src/ui/public/assets/WorkbenchView-DTgRoOkv.js +12 -0
  23. package/src/ui/public/assets/{_plugin-vue_export-helper-D9aIxbUC.js → _plugin-vue_export-helper-8ryIYVvs.js} +1 -1
  24. package/src/ui/public/assets/{configStore-CiAM1jWr.js → configStore-D77uNk8X.js} +1 -1
  25. package/src/ui/public/assets/element-plus-D8SQiesr.js +17 -0
  26. package/src/ui/public/assets/element-plus-DB_sOyH_.css +1 -0
  27. package/src/ui/public/assets/flow-mindmap-BLa-kSA7.js +90 -0
  28. package/src/ui/public/assets/flow-mindmap-C_aN-n3H.css +1 -0
  29. package/src/ui/public/assets/index-CpFreGqX.js +23 -0
  30. package/src/ui/public/assets/index-CytKCnOp.css +1 -0
  31. package/src/ui/public/assets/{socket-io-z4n873W1.js → socket-io-B1foIbJS.js} +1 -1
  32. package/src/ui/public/assets/{vendor-SOsdHF7M.js → vendor-B9orL8iU.js} +37 -37
  33. package/src/ui/public/assets/vendor-Dm_yd-By.css +1 -0
  34. package/src/ui/public/assets/{vue-flow-D5lw7RxP.js → vue-flow-BY7LODit.js} +1 -1
  35. package/src/ui/public/favicon.svg +75 -75
  36. package/src/ui/public/index.html +28 -28
  37. package/src/ui/public/logo.svg +74 -74
  38. package/src/ui/server/middleware/requestLogger.js +53 -53
  39. package/src/ui/server/routes/branchStatus.js +105 -105
  40. package/src/ui/server/routes/fileOpen.js +309 -309
  41. package/src/ui/server/routes/fs.js +791 -791
  42. package/src/ui/server/routes/git/diff.js +426 -426
  43. package/src/ui/server/routes/git/diffUtils.js +149 -149
  44. package/src/ui/server/routes/git/stash.js +550 -550
  45. package/src/ui/server/routes/git/tags.js +176 -176
  46. package/src/ui/server/routes/git.js +181 -181
  47. package/src/ui/server/routes/gitOps.js +1211 -1211
  48. package/src/ui/server/routes/process.js +86 -86
  49. package/src/ui/server/routes/status.js +67 -67
  50. package/src/ui/server/utils/createSavePortToFile.js +48 -48
  51. package/src/ui/server/utils/instanceRegistry.js +282 -282
  52. package/src/ui/server/utils/startServerOnAvailablePort.js +116 -116
  53. package/src/utils/index.js +1168 -1168
  54. package/src/ui/public/assets/AppVersionBadge-ZginY_T5.js +0 -2
  55. package/src/ui/public/assets/BranchSelector-BD8y_UMH.js +0 -1
  56. package/src/ui/public/assets/CommitForm-B1DJxyib.js +0 -6
  57. package/src/ui/public/assets/ConsoleView-EJa9vjMa.js +0 -17
  58. package/src/ui/public/assets/CustomCommandManager-BRxKf9jl.js +0 -1
  59. package/src/ui/public/assets/EditorView-DR2O8qKP.js +0 -0
  60. package/src/ui/public/assets/FlowOrchestrationWorkspace-DzSgBTmq.js +0 -21
  61. package/src/ui/public/assets/LogList-BEnd3kcT.js +0 -7
  62. package/src/ui/public/assets/MindmapView-Ds2Qd2ZL.js +0 -1
  63. package/src/ui/public/assets/MonitorView-B9t-8uJB.js +0 -1
  64. package/src/ui/public/assets/ProjectStartupButton-CHh6z5Pn.js +0 -1
  65. package/src/ui/public/assets/SourceMapView-Deai1zNG.js +0 -3
  66. package/src/ui/public/assets/TemplateManager-Eu4YhYCk.js +0 -1
  67. package/src/ui/public/assets/UserInputNode-C0GJHtef.js +0 -1
  68. package/src/ui/public/assets/WorkbenchView-C2xSxJfg.css +0 -1
  69. package/src/ui/public/assets/WorkbenchView-DUS0hiHf.js +0 -12
  70. package/src/ui/public/assets/element-plus-DB-NeCl9.js +0 -24
  71. package/src/ui/public/assets/element-plus-R1nMzM9j.css +0 -1
  72. package/src/ui/public/assets/flow-mindmap-CI2OSS19.css +0 -1
  73. package/src/ui/public/assets/flow-mindmap-DrcjupFb.js +0 -90
  74. package/src/ui/public/assets/index-Dleiy2zi.js +0 -23
  75. package/src/ui/public/assets/index-DltbTqIJ.css +0 -1
  76. package/src/ui/public/assets/vendor-CT8e9rs3.css +0 -1
  77. package/src/ui/server/.claude/codediff.txt +0 -8
@@ -1 +0,0 @@
1
- .svg-icon[data-v-833e113a]{vertical-align:middle;flex-shrink:0;display:inline-block}.form-wrap[data-v-cc2c26ba]{flex-direction:column;gap:18px;width:100%;display:flex}.field[data-v-cc2c26ba]{flex-direction:column;gap:6px;display:flex}.field-label[data-v-cc2c26ba]{color:var(--text);align-items:center;gap:4px;font-size:13px;font-weight:500;display:flex}.required[data-v-cc2c26ba]{color:var(--error);font-size:12px}.input-wrap[data-v-cc2c26ba]{align-items:center;display:flex;position:relative}.field-icon[data-v-cc2c26ba]{pointer-events:none;flex-shrink:0;position:absolute;left:10px}.text-input[data-v-cc2c26ba]{background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius);width:100%;height:38px;color:var(--text);font-family:var(--font);transition:border-color var(--t),box-shadow var(--t);outline:none;padding:0 36px 0 34px;font-size:13.5px}.text-input[data-v-cc2c26ba]::placeholder{color:var(--text-muted)}.text-input[data-v-cc2c26ba]:focus{border-color:var(--border-focus);box-shadow:0 0 0 3px var(--primary-dim)}.eye-btn[data-v-cc2c26ba]{cursor:pointer;color:var(--text-dim);transition:color var(--t);background:0 0;border:none;border-radius:4px;align-items:center;padding:4px;display:flex;position:absolute;right:8px}.eye-btn[data-v-cc2c26ba]:hover{color:var(--text)}.combobox[data-v-cc2c26ba]{position:relative}.combo-input-row[data-v-cc2c26ba]{background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius);transition:border-color var(--t),box-shadow var(--t);align-items:center;display:flex}.combobox.open .combo-input-row[data-v-cc2c26ba],.combo-input-row[data-v-cc2c26ba]:focus-within{border-color:var(--border-focus);box-shadow:0 0 0 3px var(--primary-dim)}.combobox.error .combo-input-row[data-v-cc2c26ba]{border-color:var(--error)}.combo-input[data-v-cc2c26ba]{height:38px;color:var(--text);font-family:var(--font);background:0 0;border:none;outline:none;flex:1;padding:0 4px 0 34px;font-size:13.5px}.combo-input[data-v-cc2c26ba]::placeholder{color:var(--text-muted)}.combo-arrow[data-v-cc2c26ba]{cursor:pointer;height:38px;color:var(--text-dim);transition:color var(--t);border-radius:0 var(--radius) var(--radius) 0;background:0 0;border:none;align-items:center;padding:0 10px;display:flex}.combo-arrow[data-v-cc2c26ba]:hover{color:var(--text)}.combo-clear[data-v-cc2c26ba]{cursor:pointer;height:38px;color:var(--text-dim);transition:color var(--t);background:0 0;border:none;flex-shrink:0;align-items:center;padding:0 6px;display:flex}.combo-clear[data-v-cc2c26ba]:hover{color:var(--text)}.text-input.has-two-right[data-v-cc2c26ba]{padding-right:68px}.dropdown[data-v-cc2c26ba]{z-index:200;background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius);max-height:220px;padding:4px;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;box-shadow:0 8px 24px #00000073}.dropdown-item[data-v-cc2c26ba]{cursor:pointer;text-align:left;width:100%;transition:background var(--t);background:0 0;border:none;border-radius:6px;grid-template-columns:1fr auto auto;align-items:center;gap:8px;padding:8px 10px;display:grid}.dropdown-item.has-logo[data-v-cc2c26ba]{grid-template-columns:20px 1fr auto auto}.dropdown-item[data-v-cc2c26ba]:hover{background:var(--bg-surface)}.dropdown-item.active[data-v-cc2c26ba]{background:var(--primary-dim)}.item-label[data-v-cc2c26ba]{color:var(--text);white-space:nowrap;text-overflow:ellipsis;font-size:13px;font-weight:500;overflow:hidden}.item-url[data-v-cc2c26ba]{color:var(--text-dim);white-space:nowrap;text-overflow:ellipsis;font-size:11.5px;overflow:hidden}.item-check[data-v-cc2c26ba]{flex-shrink:0}.item-logo[data-v-cc2c26ba]{object-fit:contain;border-radius:3px;flex-shrink:0;width:16px;height:16px}.provider-logo[data-v-cc2c26ba]{object-fit:contain;pointer-events:none;width:15px;height:15px}.dropdown-loading[data-v-cc2c26ba]{color:var(--text-dim);align-items:center;gap:8px;padding:10px 12px;font-size:13px;display:flex}.field-error[data-v-cc2c26ba]{color:var(--error);align-items:center;gap:5px;font-size:12px;display:flex}.test-banner[data-v-cc2c26ba]{border-radius:var(--radius);align-items:center;gap:8px;padding:10px 14px;font-size:13px;font-weight:500;display:flex}.test-banner.ok[data-v-cc2c26ba]{background:var(--success-dim);color:var(--success);border:1px solid #10b98140}.test-banner.fail[data-v-cc2c26ba]{background:var(--error-dim);color:var(--error);border:1px solid #ef444440}.actions[data-v-cc2c26ba]{justify-content:space-between;align-items:center;gap:10px;padding-top:4px;display:flex}.actions-right[data-v-cc2c26ba]{gap:10px;display:flex}.btn[data-v-cc2c26ba]{border-radius:var(--radius);height:36px;font-family:var(--font);cursor:pointer;transition:background var(--t),border-color var(--t),opacity var(--t),box-shadow var(--t);white-space:nowrap;border:1px solid #0000;align-items:center;gap:6px;padding:0 16px;font-size:13px;font-weight:500;display:inline-flex}.btn[data-v-cc2c26ba]:disabled{opacity:.5;cursor:not-allowed}.btn-ghost[data-v-cc2c26ba]{color:var(--text-dim);background:0 0;border-color:#0000}.btn-ghost[data-v-cc2c26ba]:hover:not(:disabled){color:var(--text);background:var(--bg-elevated)}.btn-outline[data-v-cc2c26ba]{color:var(--text);border-color:var(--border);background:0 0}.btn-outline[data-v-cc2c26ba]:hover:not(:disabled){border-color:var(--border-focus);background:var(--primary-dim);color:var(--primary)}.btn-primary[data-v-cc2c26ba]{background:var(--primary);color:#fff;border-color:var(--primary)}.btn-primary[data-v-cc2c26ba]:hover:not(:disabled){background:var(--primary-hover);border-color:var(--primary-hover);box-shadow:0 0 0 3px var(--primary-dim)}.banner-enter-active[data-v-cc2c26ba],.banner-leave-active[data-v-cc2c26ba]{transition:opacity .2s,transform .2s}.banner-enter-from[data-v-cc2c26ba],.banner-leave-to[data-v-cc2c26ba]{opacity:0;transform:translateY(-4px)}@keyframes spin-cc2c26ba{to{transform:rotate(360deg)}}.spin[data-v-cc2c26ba]{animation:.8s linear infinite spin-cc2c26ba}.svg-icon[data-v-5461fefd]{vertical-align:middle;flex-shrink:0;display:inline-block}.modal-overlay[data-v-6b779d4c]{-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:10000;background:#0009;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.modal-container[data-v-6b779d4c]{background:#1a1d27;border:1px solid #2d3348;border-radius:12px;flex-direction:column;width:860px;max-width:95vw;height:600px;max-height:85vh;display:flex;overflow:hidden;box-shadow:0 20px 60px #00000080}.modal-header[data-v-6b779d4c]{background:#222633;border-bottom:1px solid #2d3348;justify-content:space-between;align-items:center;padding:14px 20px;display:flex}.modal-title[data-v-6b779d4c]{color:#e4e7ef;align-items:center;gap:8px;margin:0;font-size:1rem;font-weight:600;display:flex}.close-btn[data-v-6b779d4c]{color:#8b91a8;cursor:pointer;background:0 0;border:none;border-radius:4px;justify-content:center;align-items:center;padding:4px 8px;transition:all .15s;display:inline-flex}.close-btn[data-v-6b779d4c]:hover{color:#f87171;background:#f871711a}.toolbar[data-v-6b779d4c]{background:#1e2230;border-bottom:1px solid #2d3348;align-items:center;gap:6px;padding:10px 16px;display:flex}.tool-btn[data-v-6b779d4c]{color:#e4e7ef;cursor:pointer;white-space:nowrap;background:#2d3348;border:1px solid #3a4160;border-radius:6px;align-items:center;gap:4px;padding:5px 12px;font-size:.82rem;transition:all .15s;display:inline-flex}.tool-btn[data-v-6b779d4c]:hover:not(:disabled){background:#3a4160}.tool-btn[data-v-6b779d4c]:disabled{opacity:.4;cursor:not-allowed}.path-breadcrumb[data-v-6b779d4c]{color:#8b91a8;flex:1;align-items:center;gap:0;margin-left:8px;font-size:.84rem;display:flex;overflow-x:auto}.breadcrumb-item[data-v-6b779d4c]{cursor:pointer;white-space:nowrap;align-items:center;display:flex}.breadcrumb-text[data-v-6b779d4c]{border-radius:3px;padding:2px 4px;transition:all .15s}.breadcrumb-item:hover .breadcrumb-text[data-v-6b779d4c]{color:#6c8cff;background:#6c8cff26}.breadcrumb-sep[data-v-6b779d4c]{color:#4a5178;margin:0 2px}.search-bar[data-v-6b779d4c]{background:#1e2230;border-bottom:1px solid #2d3348;align-items:center;gap:8px;padding:8px 16px;display:flex}.search-icon[data-v-6b779d4c]{flex-shrink:0}.search-input[data-v-6b779d4c]{color:#e4e7ef;background:#2d3348;border:1px solid #3a4160;border-radius:6px;outline:none;flex:1;padding:6px 10px;font-size:.85rem;transition:border-color .2s}.search-input[data-v-6b779d4c]::placeholder{color:#5a6180}.search-input[data-v-6b779d4c]:focus{border-color:#6c8cff}.clear-search[data-v-6b779d4c]{color:#8b91a8;cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:4px;display:inline-flex}.clear-search[data-v-6b779d4c]:hover{color:#f87171}.global-toggle[data-v-6b779d4c]{flex-shrink:0;align-items:center;gap:6px;margin-left:4px;display:flex}.toggle-label[data-v-6b779d4c]{color:#5a6180;-webkit-user-select:none;user-select:none;white-space:nowrap;font-size:.78rem}.toggle-switch[data-v-6b779d4c]{cursor:pointer;background:#2d3348;border:1px solid #3a4160;border-radius:9px;flex-shrink:0;width:34px;height:18px;padding:0;transition:all .2s;position:relative}.toggle-switch.active[data-v-6b779d4c]{background:#6c8cff;border-color:#6c8cff}.toggle-knob[data-v-6b779d4c]{background:#8b91a8;border-radius:50%;width:12px;height:12px;transition:all .2s;position:absolute;top:2px;left:2px}.toggle-switch.active .toggle-knob[data-v-6b779d4c]{background:#fff;left:18px}.index-status[data-v-6b779d4c]{cursor:pointer;border-radius:4px;flex-shrink:0;align-items:center;gap:4px;padding:2px 6px;transition:all .15s;display:flex}.index-status[data-v-6b779d4c]:hover{background:#ffffff0d}.status-dot[data-v-6b779d4c]{border-radius:50%;flex-shrink:0;width:6px;height:6px}.index-status.ready .status-dot[data-v-6b779d4c]{background:#34d399;box-shadow:0 0 4px #34d399}.index-status.indexing .status-dot[data-v-6b779d4c]{background:#f59e0b;animation:1s ease-in-out infinite pulse-6b779d4c}.index-status.idle .status-dot[data-v-6b779d4c],.index-status.error .status-dot[data-v-6b779d4c]{background:#6b7280}@keyframes pulse-6b779d4c{0%,to{opacity:1}50%{opacity:.4}}.status-text[data-v-6b779d4c]{color:#5a6180;white-space:nowrap;font-size:.72rem}.engine-badge[data-v-6b779d4c]{white-space:nowrap;border-radius:3px;padding:1px 6px;font-size:.72rem}.engine-badge.index[data-v-6b779d4c]{color:#34d399;background:#34d39926}.engine-badge.walk[data-v-6b779d4c]{color:#f59e0b;background:#f59e0b26}.modal-body[data-v-6b779d4c]{flex:1;display:flex;overflow:hidden}.sidebar[data-v-6b779d4c]{background:#1e2230;border-right:1px solid #2d3348;flex-shrink:0;width:160px;overflow-y:auto}.sidebar-section[data-v-6b779d4c]{border-bottom:1px solid #2d3348;padding:10px 0}.sidebar-title[data-v-6b779d4c]{color:#5a6180;text-transform:uppercase;letter-spacing:.5px;padding:0 14px 6px;font-size:.72rem;font-weight:600}.sidebar-item[data-v-6b779d4c]{color:#c0c5d8;cursor:pointer;align-items:center;gap:6px;padding:6px 14px;font-size:.82rem;transition:all .15s;display:flex}.sidebar-item[data-v-6b779d4c]:hover{color:#6c8cff;background:#6c8cff14}.sidebar-item.active[data-v-6b779d4c]{color:#6c8cff;background:#6c8cff26}.file-list-container[data-v-6b779d4c]{flex:1;padding:4px 0;overflow-y:auto}.loading-state[data-v-6b779d4c],.error-state[data-v-6b779d4c],.empty-state[data-v-6b779d4c]{color:#8b91a8;justify-content:center;align-items:center;gap:8px;height:200px;font-size:.9rem;display:flex}.error-state[data-v-6b779d4c]{color:#f87171}.spinner[data-v-6b779d4c]{vertical-align:middle;border:2px solid #2d3348;border-top-color:#6c8cff;border-radius:50%;width:16px;height:16px;margin-right:8px;animation:.6s linear infinite spin-6b779d4c;display:inline-block}@keyframes spin-6b779d4c{to{transform:rotate(360deg)}}.file-list[data-v-6b779d4c]{padding:0}.search-results-info[data-v-6b779d4c]{color:#8b91a8;background:#6c8cff0a;border-bottom:1px solid #2d3348;align-items:center;gap:6px;padding:8px 16px;font-size:.8rem;display:flex}.search-results-info strong[data-v-6b779d4c]{color:#6c8cff}.truncated-hint[data-v-6b779d4c]{color:#f59e0b;font-size:.76rem}.elapsed-hint[data-v-6b779d4c]{color:#5a6180;margin-left:auto;font-size:.74rem}.file-parent-path[data-v-6b779d4c]{color:#5a6180;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0;max-width:200px;font-family:Cascadia Code,Fira Code,Consolas,monospace;font-size:.76rem;overflow:hidden}.select-current-dir[data-v-6b779d4c]{background:#a78bfa14;border-bottom:1px solid #a78bfa26}.select-current-dir[data-v-6b779d4c]:hover{background:#a78bfa26}.select-current-dir.selected[data-v-6b779d4c]{background:#a78bfa40}.file-path-hint[data-v-6b779d4c]{color:#8b91a8;text-overflow:ellipsis;white-space:nowrap;font-family:Cascadia Code,Fira Code,Consolas,monospace;font-size:.78rem;overflow:hidden}.file-row[data-v-6b779d4c]{cursor:pointer;-webkit-user-select:none;user-select:none;border-bottom:1px solid #2d33484d;align-items:center;gap:8px;padding:7px 16px;transition:background .1s;display:flex}.file-row[data-v-6b779d4c]:hover{background:#6c8cff0f}.file-row.selected[data-v-6b779d4c]{background:#6c8cff26;border-color:#6c8cff33}.file-row.is-directory .file-name[data-v-6b779d4c]{font-weight:500}.file-icon[data-v-6b779d4c]{flex-shrink:0;align-items:center;display:inline-flex}.file-name[data-v-6b779d4c]{text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:.88rem;overflow:hidden}.file-size[data-v-6b779d4c]{color:#8b91a8;white-space:nowrap;text-align:right;flex-shrink:0;width:70px;font-size:.78rem}.file-modified[data-v-6b779d4c]{color:#5a6180;white-space:nowrap;text-align:right;flex-shrink:0;width:120px;font-size:.76rem}.modal-footer[data-v-6b779d4c]{background:#222633;border-top:1px solid #2d3348;justify-content:space-between;align-items:center;padding:12px 20px;display:flex}.selected-info[data-v-6b779d4c]{font-size:.85rem}.no-selection[data-v-6b779d4c]{color:#5a6180}.has-selection[data-v-6b779d4c]{color:#6c8cff}.single-path[data-v-6b779d4c]{text-overflow:ellipsis;white-space:nowrap;max-width:420px;font-family:Cascadia Code,Fira Code,Consolas,monospace;font-size:.82rem;display:block;overflow:hidden}.footer-actions[data-v-6b779d4c]{gap:10px;display:flex}.btn[data-v-6b779d4c]{cursor:pointer;border:none;border-radius:6px;justify-content:center;align-items:center;padding:8px 20px;font-size:.88rem;font-weight:600;transition:all .15s;display:inline-flex}.btn[data-v-6b779d4c]:disabled{opacity:.4;cursor:not-allowed}.btn-cancel[data-v-6b779d4c]{color:#c0c5d8;background:#2d3348}.btn-cancel[data-v-6b779d4c]:hover:not(:disabled){background:#3a4160}.btn-confirm[data-v-6b779d4c]{color:#fff;background:#6c8cff}.btn-confirm[data-v-6b779d4c]:hover:not(:disabled){background:#5a7af0}.mkdir-overlay[data-v-6b779d4c]{-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);z-index:20;background:#0000008c;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.mkdir-dialog[data-v-6b779d4c]{background:#1e2230;border:1px solid #3a4160;border-radius:10px;flex-direction:column;width:420px;max-width:90%;display:flex;overflow:hidden;box-shadow:0 16px 40px #00000080}.mkdir-header[data-v-6b779d4c]{background:#222633;border-bottom:1px solid #2d3348;align-items:center;gap:8px;padding:14px 18px;display:flex}.mkdir-title[data-v-6b779d4c]{color:#e4e7ef;font-size:.95rem;font-weight:600}.mkdir-body[data-v-6b779d4c]{flex-direction:column;gap:6px;padding:16px 18px;display:flex}.mkdir-label[data-v-6b779d4c]{color:#8b91a8;margin-top:4px;font-size:.78rem}.mkdir-parent-path[data-v-6b779d4c]{color:#c0c5d8;text-overflow:ellipsis;white-space:nowrap;text-align:left;direction:rtl;background:#2d3348;border:1px solid #3a4160;border-radius:6px;padding:6px 10px;font-family:Cascadia Code,Fira Code,Consolas,monospace;font-size:.82rem;overflow:hidden}.mkdir-input[data-v-6b779d4c]{color:#e4e7ef;background:#2d3348;border:1px solid #3a4160;border-radius:6px;outline:none;padding:8px 10px;font-size:.9rem;transition:border-color .15s}.mkdir-input[data-v-6b779d4c]::placeholder{color:#5a6180}.mkdir-input[data-v-6b779d4c]:focus{border-color:#a78bfa}.mkdir-input.has-error[data-v-6b779d4c]{border-color:#f87171}.mkdir-error[data-v-6b779d4c]{color:#f87171;align-items:center;gap:4px;margin-top:4px;font-size:.78rem;display:flex}.mkdir-hint[data-v-6b779d4c]{color:#5a6180;margin-top:4px;font-size:.76rem}.mkdir-footer[data-v-6b779d4c]{background:#1a1d27;border-top:1px solid #2d3348;justify-content:flex-end;gap:10px;padding:12px 18px;display:flex}.modal-theme-light[data-v-6b779d4c]{background:#fff;border-color:#e2e8f0;box-shadow:0 20px 60px #00000026}.modal-theme-light .modal-header[data-v-6b779d4c]{background:#f8fafc;border-color:#e2e8f0}.modal-theme-light .modal-title[data-v-6b779d4c]{color:#1e293b}.modal-theme-light .close-btn[data-v-6b779d4c]{color:#64748b}.modal-theme-light .close-btn[data-v-6b779d4c]:hover{color:#ef4444;background:#ef444414}.modal-theme-light .toolbar[data-v-6b779d4c]{background:#f1f5f9;border-color:#e2e8f0}.modal-theme-light .tool-btn[data-v-6b779d4c]{color:#1e293b;background:#e2e8f0;border-color:#cbd5e1}.modal-theme-light .tool-btn[data-v-6b779d4c]:hover:not(:disabled){background:#cbd5e1}.modal-theme-light .path-breadcrumb[data-v-6b779d4c]{color:#64748b}.modal-theme-light .breadcrumb-item:hover .breadcrumb-text[data-v-6b779d4c]{color:#3b82f6;background:#3b82f61a}.modal-theme-light .breadcrumb-sep[data-v-6b779d4c]{color:#cbd5e1}.modal-theme-light .search-bar[data-v-6b779d4c]{background:#f1f5f9;border-color:#e2e8f0}.modal-theme-light .search-input[data-v-6b779d4c]{color:#1e293b;background:#fff;border-color:#cbd5e1}.modal-theme-light .search-input[data-v-6b779d4c]::placeholder{color:#94a3b8}.modal-theme-light .search-input[data-v-6b779d4c]:focus{border-color:#3b82f6}.modal-theme-light .toggle-label[data-v-6b779d4c]{color:#94a3b8}.modal-theme-light .toggle-switch[data-v-6b779d4c]{background:#e2e8f0;border-color:#cbd5e1}.modal-theme-light .toggle-switch.active[data-v-6b779d4c]{background:#3b82f6;border-color:#3b82f6}.modal-theme-light .toggle-knob[data-v-6b779d4c]{background:#94a3b8}.modal-theme-light .sidebar[data-v-6b779d4c]{background:#f8fafc;border-color:#e2e8f0}.modal-theme-light .sidebar-section[data-v-6b779d4c]{border-color:#e2e8f0}.modal-theme-light .sidebar-title[data-v-6b779d4c]{color:#94a3b8}.modal-theme-light .sidebar-item[data-v-6b779d4c]{color:#475569}.modal-theme-light .sidebar-item[data-v-6b779d4c]:hover{color:#3b82f6;background:#3b82f60f}.modal-theme-light .sidebar-item.active[data-v-6b779d4c]{color:#3b82f6;background:#3b82f61a}.modal-theme-light .file-list-container[data-v-6b779d4c]{background:#fff}.modal-theme-light .file-row[data-v-6b779d4c]{color:#1e293b;border-color:#e2e8f099}.modal-theme-light .file-row[data-v-6b779d4c]:hover{background:#3b82f60d}.modal-theme-light .file-row.selected[data-v-6b779d4c]{background:#3b82f61a;border-color:#3b82f633}.modal-theme-light .file-row.is-directory .file-name[data-v-6b779d4c]{color:#0f172a}.modal-theme-light .file-size[data-v-6b779d4c]{color:#64748b}.modal-theme-light .file-modified[data-v-6b779d4c]{color:#94a3b8}.modal-theme-light .file-path-hint[data-v-6b779d4c]{color:#64748b}.modal-theme-light .file-parent-path[data-v-6b779d4c]{color:#94a3b8}.modal-theme-light .select-current-dir[data-v-6b779d4c]{background:#8b5cf60d;border-color:#8b5cf61a}.modal-theme-light .select-current-dir[data-v-6b779d4c]:hover{background:#8b5cf61a}.modal-theme-light .select-current-dir.selected[data-v-6b779d4c]{background:#8b5cf626}.modal-theme-light .search-results-info[data-v-6b779d4c]{color:#64748b;background:#3b82f608;border-color:#e2e8f0}.modal-theme-light .loading-state[data-v-6b779d4c],.modal-theme-light .empty-state[data-v-6b779d4c]{color:#64748b}.modal-theme-light .error-state[data-v-6b779d4c]{color:#ef4444}.modal-theme-light .modal-footer[data-v-6b779d4c]{background:#f8fafc;border-color:#e2e8f0}.modal-theme-light .no-selection[data-v-6b779d4c]{color:#94a3b8}.modal-theme-light .has-selection[data-v-6b779d4c]{color:#3b82f6}.modal-theme-light .btn-cancel[data-v-6b779d4c]{color:#475569;background:#e2e8f0}.modal-theme-light .btn-cancel[data-v-6b779d4c]:hover:not(:disabled){background:#cbd5e1}.modal-theme-light .btn-confirm[data-v-6b779d4c]{background:#3b82f6}.modal-theme-light .btn-confirm[data-v-6b779d4c]:hover:not(:disabled){background:#2563eb}.modal-theme-light.mkdir-dialog[data-v-6b779d4c],.mkdir-dialog.modal-theme-light[data-v-6b779d4c]{background:#fff;border-color:#cbd5e1;box-shadow:0 16px 40px #00000026}.modal-theme-light .mkdir-header[data-v-6b779d4c]{background:#f8fafc;border-color:#e2e8f0}.modal-theme-light .mkdir-title[data-v-6b779d4c]{color:#1e293b}.modal-theme-light .mkdir-label[data-v-6b779d4c]{color:#64748b}.modal-theme-light .mkdir-parent-path[data-v-6b779d4c]{color:#1e293b;background:#f1f5f9;border-color:#cbd5e1}.modal-theme-light .mkdir-input[data-v-6b779d4c]{color:#1e293b;background:#fff;border-color:#cbd5e1}.modal-theme-light .mkdir-input[data-v-6b779d4c]:focus{border-color:#8b5cf6}.modal-theme-light .mkdir-footer[data-v-6b779d4c]{background:#f8fafc;border-color:#e2e8f0}
@@ -1,8 +0,0 @@
1
- {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\utils\\createSavePortToFile.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027./logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\utils\\createSavePortToFile.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027./logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport logger from \u0027../utils/logger.js\u0027\n\nexport function createSavePortToFile({ savePort, fs, path, cwdFn = process.cwd }) {\n let savedPort = null;\n\n return async function savePortToFile(port) {\n try {\n if (savePort \u0026\u0026 savedPort !== port) {\n savedPort = port;\n\n const portFilePath = path.join(cwdFn(), \u0027.port\u0027);\n await fs.writeFile(portFilePath, port.toString(), \u0027utf8\u0027);\n logger.info(`端口号 ${port} 已保存到 ${portFilePath}`);\n\n try {\n const clientPath = path.join(cwdFn(), \u0027src\u0027, \u0027ui\u0027, \u0027client\u0027);\n const envPath = path.join(clientPath, \u0027.env.local\u0027);\n\n await fs.access(clientPath).catch(() =\u003e {\n logger.info(`客户端目录 ${clientPath} 不存在,跳过环境变量设置`);\n return Promise.reject(new Error(\u0027Client directory not found\u0027));\n });\n\n await fs.writeFile(envPath, `VITE_BACKEND_PORT=${port}\\n`, \u0027utf8\u0027);\n logger.info(`端口号环境变量已保存到 ${envPath}`);\n } catch (envError) {\n logger.error(\u0027保存端口号到环境变量失败,但不影响主要功能:\u0027, envError);\n }\n }\n } catch (error) {\n logger.error(\u0027保存端口号到文件失败:\u0027, error);\n }\n };\n}\n","structuredPatch":[{"oldStart":12,"oldLines":7,"newStart":12,"newLines":7,"lines":[" // See the License for the specific language governing permissions and"," // limitations under the License."," //","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027./logger.js\u0027"," "," export function createSavePortToFile({ savePort, fs, path, cwdFn = process.cwd }) {"," let savedPort = null;"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f01","duration_ms":6,"timestamp":"2026-06-27 09:49:16"}
2
- {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\utils\\instanceRegistry.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027./logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\utils\\instanceRegistry.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027./logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\n// 实例注册表工具\n// 维护 ~/.zen-gitsync-instances.json,记录所有正在运行的 GUI 实例\n// 多进程并发写采用 atomic temp+rename + 进程内串行化 Promise 链\n// stale 判定:PID 不存在 或 lastHeartbeat 超过阈值\n\nimport nodePath from \u0027node:path\u0027;\nimport logger from \u0027../utils/logger.js\u0027\nimport nodeOs from \u0027node:os\u0027;\n\nconst STALE_MS = 30_000; // 心跳超时阈值(毫秒)\nconst WATCH_DEBOUNCE_MS = 100; // fs.watch 防抖时间\nconst REGISTRY_VERSION = 1;\n\n/**\n * 默认注册表文件路径(与 createInstanceRegistry({ registryPath }) 的约定一致)\n * 用于:npm.js 的 /api/app-restart 自拉起时,父进程轮询此路径判断子进程是否就绪。\n */\nexport function getRegistryPath() {\n return nodePath.join(nodeOs.homedir(), \u0027.zen-gitsync-instances.json\u0027);\n}\n\nfunction isProcessAlive(pid) {\n if (!Number.isInteger(pid) || pid \u003c= 0) return false;\n try {\n // 信号 0 仅做存活检查,不真正发送信号\n process.kill(pid, 0);\n return true;\n } catch (err) {\n if (err \u0026\u0026 (err.code === \u0027ESRCH\u0027 || err.code === \u0027ENOENT\u0027)) {\n return false;\n }\n // EPERM 等情况:进程存在但权限不足,按 alive 处理\n return true;\n }\n}\n\n// 解析项目名:优先 package.json.name,兜底为目录 basename\nasync function resolveProjectName(projectPath, fsMod, pathMod) {\n if (!projectPath) return \u0027\u0027;\n try {\n const pkgPath = pathMod.join(projectPath, \u0027package.json\u0027);\n const raw = await fsMod.readFile(pkgPath, \u0027utf-8\u0027);\n const pkg = JSON.parse(raw);\n if (pkg \u0026\u0026 typeof pkg.name === \u0027string\u0027 \u0026\u0026 pkg.name.trim()) {\n return pkg.name.trim();\n }\n } catch (_) {\n // 读失败或解析失败,兜底\n }\n return pathMod.basename(projectPath);\n}\n\nexport function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, registryPath }) {\n if (!fsMod || !pathMod || !osMod || !registryPath) {\n throw new Error(\u0027createInstanceRegistry: 必须提供 fs/path/os/registryPath\u0027);\n }\n\n // 进程内写串行化:所有 mutate 操作都 await 这条链\n let writeChain = Promise.resolve();\n\n function enqueueWrite(task) {\n const next = writeChain.then(task, task);\n // 不让单个失败阻塞后续操作\n writeChain = next.catch(() =\u003e {});\n return next;\n }\n\n async function readAll() {\n try {\n const raw = await fsMod.readFile(registryPath, \u0027utf-8\u0027);\n const parsed = JSON.parse(raw);\n if (parsed \u0026\u0026 typeof parsed === \u0027object\u0027 \u0026\u0026 parsed.instances \u0026\u0026 typeof parsed.instances === \u0027object\u0027) {\n return parsed;\n }\n return { version: REGISTRY_VERSION, instances: {} };\n } catch (err) {\n if (err \u0026\u0026 err.code === \u0027ENOENT\u0027) {\n return { version: REGISTRY_VERSION, instances: {} };\n }\n logger.warn(`[instanceRegistry] 读取注册表失败,按空表处理: ${err?.message || err}`);\n return { version: REGISTRY_VERSION, instances: {} };\n }\n }\n\n async function writeAll(obj) {\n const payload = {\n version: REGISTRY_VERSION,\n ...obj,\n instances: obj.instances || {}\n };\n const tmpPath = `${registryPath}.tmp`;\n await fsMod.writeFile(tmpPath, JSON.stringify(payload, null, 2), \u0027utf-8\u0027);\n await fsMod.rename(tmpPath, registryPath);\n }\n\n // 同步裁剪:传入当前内存中的 instances 字典,返回裁剪后的新字典\n function pruneInPlace(instances) {\n const now = Date.now();\n const result = {};\n for (const [pidStr, entry] of Object.entries(instances)) {\n if (!entry || typeof entry !== \u0027object\u0027) continue;\n const pid = Number(entry.pid ?? Number(pidStr));\n if (!isProcessAlive(pid)) continue;\n if (typeof entry.lastHeartbeat === \u0027number\u0027 \u0026\u0026 now - entry.lastHeartbeat \u003e STALE_MS) continue;\n result[pidStr] = entry;\n }\n return result;\n }\n\n // 公开 API\n async function register({ pid, port, projectPath, projectName, hostname } = {}) {\n if (!Number.isInteger(pid) || pid \u003c= 0) throw new Error(\u0027register: pid 必填\u0027);\n if (!Number.isInteger(port) || port \u003c= 0) throw new Error(\u0027register: port 必填\u0027);\n if (!projectPath) throw new Error(\u0027register: projectPath 必填\u0027);\n\n const resolvedName = projectName \u0026\u0026 String(projectName).trim()\n ? String(projectName).trim()\n : await resolveProjectName(projectPath, fsMod, pathMod);\n\n const entry = {\n pid,\n port,\n projectName: resolvedName,\n projectPath,\n startedAt: Date.now(),\n lastHeartbeat: Date.now(),\n hostname: hostname || osMod.hostname()\n };\n\n await enqueueWrite(async () =\u003e {\n const obj = await readAll();\n obj.instances[String(pid)] = entry;\n await writeAll(obj);\n });\n return entry;\n }\n\n async function unregister(pid) {\n if (!Number.isInteger(pid) || pid \u003c= 0) return;\n await enqueueWrite(async () =\u003e {\n const obj = await readAll();\n if (obj.instances \u0026\u0026 obj.instances[String(pid)]) {\n delete obj.instances[String(pid)];\n await writeAll(obj);\n }\n });\n }\n\n async function heartbeat(pid, updates = {}) {\n if (!Number.isInteger(pid) || pid \u003c= 0) return;\n await enqueueWrite(async () =\u003e {\n const obj = await readAll();\n const key = String(pid);\n const existing = obj.instances[key];\n if (!existing) {\n // 如果心跳时条目不存在(被裁剪或被外部清理),跳过;\n // 由调用方负责周期性 re-register\n return;\n }\n obj.instances[key] = {\n ...existing,\n ...(updates.projectPath ? { projectPath: updates.projectPath } : {}),\n ...(updates.projectName ? { projectName: updates.projectName } : {}),\n lastHeartbeat: Date.now()\n };\n await writeAll(obj);\n });\n }\n\n async function list({ pruneStale = true } = {}) {\n const obj = await readAll();\n let instances = obj.instances || {};\n if (pruneStale) {\n instances = pruneInPlace(instances);\n // 如果发生了裁剪,持久化回去\n const hasChange = Object.keys(instances).length !== Object.keys(obj.instances || {}).length;\n if (hasChange) {\n await enqueueWrite(async () =\u003e {\n const fresh = await readAll();\n fresh.instances = pruneInPlace(fresh.instances || {});\n await writeAll(fresh);\n });\n }\n }\n const arr = Object.values(instances);\n arr.sort((a, b) =\u003e (a.port || 0) - (b.port || 0));\n return arr;\n }\n\n // 监听注册表文件变化;callback 会在 debounce 后被调用,参数是最新 list\n // fsWatch 参数:node \u0027fs\u0027 模块的 watch 函数(同步 + EventEmitter 形式)\n function watch(callback, fsWatch) {\n if (typeof callback !== \u0027function\u0027) {\n throw new Error(\u0027watch: callback 必填\u0027);\n }\n if (typeof fsWatch !== \u0027function\u0027) {\n logger.warn(\u0027[instanceRegistry] 未提供 fs.watch,跨进程推送将不可用\u0027);\n return function noop() {};\n }\n let debounceTimer = null;\n let watcher = null;\n let pruneTimer = null;\n let closed = false;\n\n const fire = async () =\u003e {\n if (closed) return;\n try {\n const fresh = await list({ pruneStale: true });\n callback(fresh);\n } catch (e) {\n logger.warn(`[instanceRegistry] watch 回调失败: ${e?.message || e}`);\n }\n };\n\n // 周期性 prune:即便没有其他进程写入注册表,也定期清理本地失效条目\n // (例如所有 server 都强 kill 后,文件里残留的僵尸条目会被自动清理)\n pruneTimer = setInterval(() =\u003e {\n if (closed) return;\n list({ pruneStale: true }).catch(() =\u003e {});\n }, STALE_MS / 3);\n\n try {\n watcher = fsWatch(registryPath, { persistent: false }, () =\u003e {\n if (closed) return;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(fire, WATCH_DEBOUNCE_MS);\n });\n if (watcher \u0026\u0026 typeof watcher.on === \u0027function\u0027) {\n watcher.on(\u0027error\u0027, (err) =\u003e {\n logger.warn(`[instanceRegistry] fs.watch 出错: ${err?.message || err}`);\n });\n }\n } catch (err) {\n logger.warn(`[instanceRegistry] 无法启动 fs.watch (${err?.message || err}),跨进程推送将不可用,请依赖轮询`);\n }\n\n return function closeWatcher() {\n closed = true;\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n if (pruneTimer) {\n clearInterval(pruneTimer);\n pruneTimer = null;\n }\n if (watcher) {\n try { watcher.close(); } catch (_) {}\n watcher = null;\n }\n };\n }\n\n function close() {\n // 工厂内部无 timer,仅为 API 对称\n }\n\n return {\n register,\n unregister,\n heartbeat,\n list,\n watch,\n close,\n _resolveProjectName: (p) =\u003e resolveProjectName(p, fsMod, pathMod),\n _STALE_MS: STALE_MS\n };\n}\n","structuredPatch":[{"oldStart":18,"oldLines":7,"newStart":18,"newLines":7,"lines":[" // stale 判定:PID 不存在 或 lastHeartbeat 超过阈值"," "," import nodePath from \u0027node:path\u0027;","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027./logger.js\u0027"," import nodeOs from \u0027node:os\u0027;"," "," const STALE_MS = 30_000; // 心跳超时阈值(毫秒)"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f1d","duration_ms":9,"timestamp":"2026-06-27 09:49:16"}
3
- {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diff.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027../../utils/logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diff.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027../../utils/logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport fs from \u0027fs/promises\u0027;\nimport logger from \u0027../utils/logger.js\u0027\nimport { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;\n\nimport { createDiffHelpers } from \u0027./diffUtils.js\u0027;\n\nimport { ensureWithinCwd } from \u0027../../utils/pathGuard.js\u0027;\n\n/**\n * SEC-PATH-1: resolve user path to be inside process.cwd(), returns null on escape\n * @param {string} userPath\n * @returns {Promise\u003cstring|null\u003e}\n */\nasync function safePathInProject(userPath) {\n if (typeof userPath !== \u0027string\u0027 || !userPath) return null;\n if (/[\\x00-\\x1f]/.test(userPath)) return null;\n const cwd = process.cwd();\n const result = await ensureWithinCwd(userPath, cwd);\n return result ? result.safePath : null;\n}\n\nexport function registerGitDiffRoutes({\n app,\n execGitCommand\n}) {\n const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });\n\n const skipExtensions = /\\.(min\\.js|umd\\.cjs|bundle\\.js|dist\\.js|prod\\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;\n const maxBytes = 1024 * 1024;\n\n // 获取文件差异\n app.get(\u0027/api/diff\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n \n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n \n const diffArgs = [\u0027diff\u0027, \u0027--\u0027, filePath];\n \n // 使用优化的检查函数(diffCommand 用于内部 numstat 检测的字符串转换,\n // 实际走 git 时用 argv 数组,避免 Windows cmd.exe 拼引号问题)\n const skipCheck = await checkShouldSkipDiff(filePath, `git diff -- \"${filePath}\"`);\n if (skipCheck.shouldSkip) {\n return res.json({\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n // 执行git diff命令获取文件差异\n const { stdout } = await execGitCommand(diffArgs);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json(sizeCheck);\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ diff: stdout, stats });\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n // 获取已暂存文件差异\n app.get(\u0027/api/diff-cached\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n \n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n \n const diffArgs = [\u0027diff\u0027, \u0027--cached\u0027, \u0027--\u0027, filePath];\n \n // 使用优化的检查函数\n const skipCheck = await checkShouldSkipDiff(filePath, `git diff --cached -- \"${filePath}\"`);\n if (skipCheck.shouldSkip) {\n return res.json({\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n // 执行git diff --cached命令获取已暂存文件差异\n const { stdout } = await execGitCommand(diffArgs);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json(sizeCheck);\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ diff: stdout, stats });\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n\n // 获取全量 diff(git diff HEAD,含已暂存与未暂存的所有变更)\n app.get(\u0027/api/diff-head\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027diff\u0027, \u0027HEAD\u0027]);\n const MAX = 500 * 1024;\n const content = stdout.length \u003e MAX\n ? stdout.slice(0, MAX) + \u0027\\n\\n[内容过大,已截断]\u0027\n : stdout;\n res.json({ success: true, diff: content });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取文件内容 (用于未跟踪文件)\n app.get(\u0027/api/file-content\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n\n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内,越界返回 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n try {\n // 二进制/产物文件:直接告知前端 isBinary,不读取内容\n if (skipExtensions.test(safeFilePath)) {\n const isImage = /\\.(png|jpg|jpeg|gif|webp|bmp|ico|svg)$/i.test(safeFilePath);\n return res.json({\n success: true,\n isBinary: true,\n isImage,\n content: isImage\n ? \u0027⚠️ 该文件是图片,建议在预览中查看。\u0027\n : \u0027⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。\u0027\n });\n }\n\n // 读取文件内容(走 safePath,不是 user input)\n const content = await fs.readFile(safeFilePath, \u0027utf8\u0027);\n res.json({ success: true, content });\n } catch (readError) {\n res.status(500).json({ success: false, error: `无法读取文件: ${readError.message}` });\n }\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n\n app.get(\u0027/api/git-file-content\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n const rev = req.query.rev;\n\n if (!filePath || !rev) {\n throw new HttpError(400, \u0027缺少必要参数\u0027);\n }\n\n // SEC-PATH-1:路径必须在 cwd 内,越界返回 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n if (skipExtensions.test(safeFilePath)) {\n return res.json({\n success: true,\n isBinary: true,\n content: \u0027⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。\u0027\n });\n }\n\n const r = String(rev);\n // 用 safeFilePath 而不是 user input,防注入\n const spec = r === \u0027:\u0027 ? `:${safeFilePath}` : `${r}:${safeFilePath}`;\n\n let sizeBytes = 0;\n try {\n const { stdout: sizeOut } = await execGitCommand([\u0027cat-file\u0027, \u0027-s\u0027, spec], { log: false });\n sizeBytes = parseInt(String(sizeOut).trim(), 10) || 0;\n } catch (e) {\n return res.json({ success: true, notFound: true, content: \u0027\u0027 });\n }\n\n if (sizeBytes \u003e maxBytes) {\n return res.json({\n success: true,\n isLargeFile: true,\n size: sizeBytes,\n content: `⚠️ 文件内容过大 (${(sizeBytes / 1024).toFixed(1)} KB),已跳过显示以避免浏览器卡顿。`\n });\n }\n\n const { stdout } = await execGitCommand([\u0027show\u0027, spec], { log: false });\n return res.json({ success: true, content: stdout ?? \u0027\u0027 });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 解决冲突:保存解决后的文件内容\n app.post(\u0027/api/resolve-conflict\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { filePath, content } = req.body;\n\n if (!filePath) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件路径参数\u0027\n });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内,越界 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n if (content === undefined) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件内容参数\u0027\n });\n }\n\n try {\n // 写入解决后的内容到文件(走 safePath)\n await fs.writeFile(safeFilePath, content, \u0027utf8\u0027);\n\n res.json({\n success: true,\n message: \u0027冲突已解决,文件已更新\u0027\n });\n } catch (writeError) {\n res.status(500).json({\n success: false,\n error: `保存文件失败: ${writeError.message}`\n });\n }\n } catch (error) {\n logger.error(\u0027解决冲突失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `解决冲突失败: ${error.message}`\n });\n }\n }));\n\n // 撤回文件修改\n app.post(\u0027/api/revert_file\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { filePath } = req.body;\n\n if (!filePath) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件路径参数\u0027\n });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n // 检查文件状态:未跟踪文件需要删除,修改文件需要恢复\n const { stdout: statusOutput } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--\u0027, safeFilePath]);\n\n // 未跟踪的文件 (??), 需要删除它\n if (statusOutput.startsWith(\u0027??\u0027)) {\n try {\n await fs.unlink(safeFilePath);\n return res.json({ success: true, message: \u0027未跟踪的文件已删除\u0027 });\n } catch (error) {\n return res.status(500).json({\n success: false,\n error: `删除文件失败: ${error.message}`\n });\n }\n }\n // 已暂存的文件,先取消暂存\n else if (statusOutput.startsWith(\u0027A \u0027) || statusOutput.startsWith(\u0027M \u0027) || statusOutput.startsWith(\u0027D \u0027)) {\n // 先取消暂存\n await execGitCommand([\u0027reset\u0027, \u0027HEAD\u0027, \u0027--\u0027, safeFilePath]);\n }\n\n // 已修改文件,取消所有本地修改\n if (statusOutput) {\n await execGitCommand([\u0027checkout\u0027, \u0027--\u0027, safeFilePath]);\n return res.json({ success: true, message: \u0027文件修改已撤回\u0027 });\n } else {\n return res.status(400).json({\n success: false,\n error: \u0027文件没有修改或不存在\u0027\n });\n }\n } catch (error) {\n logger.error(\u0027撤回文件修改失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `撤回文件修改失败: ${error.message}`\n });\n }\n }));\n\n // 批量撤回文件修改(未跟踪删除,已修改 checkout 还原)\n // body: { filePaths: string[] }\n // 返回: { success, count, results: [{ path, success, error? }] }\n app.post(\u0027/api/revert_files\u0027, asyncRoute(async (req, res) =\u003e {\n const filePaths = Array.isArray(req.body?.filePaths) ? req.body.filePaths : []\n if (filePaths.length === 0) {\n return res.status(400).json({ success: false, error: \u0027缺少文件路径参数\u0027 })\n }\n\n // 批量大小限制,防止 DoS\n const MAX_BATCH = 200;\n if (filePaths.length \u003e MAX_BATCH) {\n return res.status(400).json({ success: false, error: `批量大小不能超过 ${MAX_BATCH}` });\n }\n\n const results = []\n let successCount = 0\n\n for (const filePath of filePaths) {\n try {\n // SEC-PATH-1:每个 path 都校验在 cwd 内,越界直接报错\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n results.push({ path: filePath, success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n continue;\n }\n\n // 检查文件状态:未跟踪 ??、已暂存 A/M/D、已修改(空状态会返回空字符串)\n const { stdout: statusOutput } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--\u0027, safeFilePath])\n\n // 未跟踪的文件 (??) → 直接删除\n if (statusOutput.startsWith(\u0027??\u0027)) {\n try {\n await fs.unlink(safeFilePath)\n results.push({ path: filePath, success: true, message: \u0027未跟踪的文件已删除\u0027 })\n successCount++\n continue\n } catch (err) {\n results.push({ path: filePath, success: false, error: `删除文件失败: ${err?.message || err}` })\n continue\n }\n }\n\n // 已暂存的文件,先取消暂存(不影响工作区)\n if (statusOutput.startsWith(\u0027A \u0027) || statusOutput.startsWith(\u0027M \u0027) || statusOutput.startsWith(\u0027D \u0027)) {\n await execGitCommand([\u0027reset\u0027, \u0027HEAD\u0027, \u0027--\u0027, safeFilePath])\n }\n\n // 已修改文件:丢弃工作区修改\n if (statusOutput) {\n await execGitCommand([\u0027checkout\u0027, \u0027--\u0027, safeFilePath])\n results.push({ path: filePath, success: true, message: \u0027文件修改已撤回\u0027 })\n successCount++\n } else {\n // 文件已无修改(可能在并发中被处理掉了)\n results.push({ path: filePath, success: true, message: \u0027文件无修改\u0027 })\n successCount++\n }\n } catch (err) {\n results.push({ path: filePath, success: false, error: err?.message || String(err) })\n }\n }\n\n res.json({\n success: true,\n count: filePaths.length,\n successCount,\n results\n })\n }));\n}\n","structuredPatch":[{"oldStart":13,"oldLines":7,"newStart":13,"newLines":7,"lines":[" // limitations under the License."," //"," import fs from \u0027fs/promises\u0027;","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027../../utils/logger.js\u0027"," import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;"," "," import { createDiffHelpers } from \u0027./diffUtils.js\u0027;"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f27","duration_ms":5,"timestamp":"2026-06-27 09:49:17"}
4
- {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diffUtils.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027../../utils/logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diffUtils.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027../../utils/logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport logger from \u0027../utils/logger.js\u0027\n\nexport function createDiffHelpers({ execGitCommand }) {\n /**\n * 检查文件是否应该跳过diff显示(参考GitLab策略)\n * @param {string} filePath - 文件路径\n * @param {string} diffCommand - 要执行的git diff命令\n * @returns {Promise\u003c{shouldSkip: boolean, reason?: string, stats?: object}\u003e}\n */\n async function checkShouldSkipDiff(filePath, diffCommand) {\n // 1. 检查文件扩展名 - 编译/压缩/二进制文件\n const skipExtensions = /\\.(min\\.js|umd\\.cjs|bundle\\.js|dist\\.js|prod\\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;\n if (skipExtensions.test(filePath)) {\n return {\n shouldSkip: true,\n reason: \u0027⚠️ 检测到编译/打包/二进制文件,diff已跳过显示。\\n\\n提示:这类文件通常是自动生成的或二进制文件,不适合查看diff。\\n如需查看,请使用命令行。\u0027\n };\n }\n\n // 2. 使用 --numstat 快速检查变更量(不获取实际内容,速度快)\n try {\n // 把传入的 diffCommand 字符串改写成 git diff/show 加 --numstat 的 argv 数组。\n // 不再走 shell 模式,避免 Windows cmd.exe 下引号兼容问题(参 utils execGitCommand execFile 化)。\n const numstatCommand = diffCommand.replace(/git (diff|show)/, \u0027git $1 --numstat\u0027)\n const argvMatch = numstatCommand.trim().match(/^git\\s+(\\S+)\\s*(.*)$/)\n let numstatArgs\n if (argvMatch) {\n const subCmd = argvMatch[1]\n const rest = argvMatch[2]\n // 简单按空白拆分,带引号段整体保留 -- \"--\" + 路径\n // 这里最常见形态是 \u0027git diff/show -- \"--path\"\u0027 或 \u0027git diff/show hash -- \"--path\"\u0027\n const tokens = []\n const re = /\"([^\"]*)\"|(\\S+)/g\n let m\n while ((m = re.exec(rest)) !== null) tokens.push(m[1] !== undefined ? m[1] : m[2])\n // 找到 \u0027--numstat\u0027 位置,把它加到 subCmd 后面作为第一个参数\n numstatArgs = [subCmd, \u0027--numstat\u0027, ...tokens]\n } else {\n // fallback: 走旧逻辑(字符串)\n numstatArgs = numstatCommand\n }\n const { stdout: numstat } = await execGitCommand(numstatArgs, { log: false });\n\n if (numstat.trim()) {\n const lines = numstat.trim().split(\u0027\\n\u0027);\n for (const line of lines) {\n const parts = line.split(\u0027\\t\u0027);\n if (parts.length \u003e= 3) {\n const added = parts[0];\n const deleted = parts[1];\n\n // 检查是否是二进制文件(显示为 - -)\n if (added === \u0027-\u0027 \u0026\u0026 deleted === \u0027-\u0027) {\n return {\n shouldSkip: true,\n reason: \u0027⚠️ 检测到二进制文件,diff已跳过显示。\\n\\n提示:二进制文件无法以文本形式显示diff。\u0027\n };\n }\n\n // 检查变更行数是否过多(超过3000行)\n const totalChanges = parseInt(added) + parseInt(deleted);\n if (!isNaN(totalChanges) \u0026\u0026 totalChanges \u003e 3000) {\n return {\n shouldSkip: true,\n reason: `⚠️ 变更内容过大 (${totalChanges.toLocaleString()} 行变更),diff已跳过显示以避免浏览器卡顿。\\n\\n提示:建议使用命令行或专业diff工具查看大文件变更。\\n增加:${parseInt(added).toLocaleString()} 行\\n删除:${parseInt(deleted).toLocaleString()} 行`,\n stats: { added: parseInt(added), deleted: parseInt(deleted), total: totalChanges }\n };\n }\n }\n }\n }\n } catch (error) {\n // numstat失败不影响后续流程\n logger.info(\u0027numstat检查失败,继续执行:\u0027, error.message);\n }\n\n // 3. 通过了初步检查\n return { shouldSkip: false };\n }\n\n /**\n * 检查diff内容大小,如果过大则跳过\n * @param {string} diffContent - diff内容\n * @param {number} maxSizeKB - 最大大小(KB),默认500KB\n * @returns {object|null} - 如果需要跳过返回提示对象,否则返回null\n */\n function checkDiffSize(diffContent, maxSizeKB = 500) {\n const diffSizeKB = Buffer.byteLength(diffContent, \u0027utf8\u0027) / 1024;\n if (diffSizeKB \u003e maxSizeKB) {\n return {\n diff: `⚠️ Diff内容过大 (${diffSizeKB.toFixed(1)} KB),已跳过显示以避免浏览器卡顿。\\n\\n提示:建议使用命令行查看大文件diff。`,\n isLargeFile: true,\n size: diffSizeKB\n };\n }\n return null;\n }\n\n /**\n * 从 diff 内容中统计增加和删除行数\n * @param {string} diffContent - diff内容\n * @returns {object} - {added, deleted}\n */\n function getDiffStats(diffContent) {\n if (!diffContent) return { added: 0, deleted: 0 };\n\n const lines = diffContent.split(\u0027\\n\u0027);\n let added = 0;\n let deleted = 0;\n\n for (const line of lines) {\n // 跳过diff头部信息\n if (line.startsWith(\u0027diff \u0027) || line.startsWith(\u0027index \u0027) ||\n line.startsWith(\u0027--- \u0027) || line.startsWith(\u0027+++ \u0027) ||\n line.startsWith(\u0027@@ \u0027)) {\n continue;\n }\n\n // 统计增加和删除的行\n if (line.startsWith(\u0027+\u0027)) {\n added++;\n } else if (line.startsWith(\u0027-\u0027)) {\n deleted++;\n }\n }\n\n return { added, deleted };\n }\n\n return {\n checkShouldSkipDiff,\n checkDiffSize,\n getDiffStats\n };\n}\n","structuredPatch":[{"oldStart":12,"oldLines":7,"newStart":12,"newLines":7,"lines":[" // See the License for the specific language governing permissions and"," // limitations under the License."," //","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027../../utils/logger.js\u0027"," "," export function createDiffHelpers({ execGitCommand }) {"," /**"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f36","duration_ms":4,"timestamp":"2026-06-27 09:49:17"}
5
- {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\stash.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027../../utils/logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\stash.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027../../utils/logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport { createDiffHelpers } from \u0027./diffUtils.js\u0027;\nimport logger from \u0027../utils/logger.js\u0027\nimport { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;\n\nexport function registerGitStashRoutes({ app, execGitCommand, configManager }) {\n const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });\n\n // 获取stash列表\n app.get(\u0027/api/stash-list\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027list\u0027]);\n \n // 解析stash列表\n const stashList = stdout.split(\u0027\\n\u0027)\n .filter(Boolean)\n .map(line =\u003e {\n // 尝试解析stash行,格式类似: stash@{0}: WIP on branch: commit message\n const match = line.match(/^(stash@\\{\\d+\\}): (.+)$/);\n if (match) {\n return {\n id: match[1],\n description: match[2]\n };\n }\n return null;\n })\n .filter(item =\u003e item !== null);\n \n res.json({ success: true, stashes: stashList });\n } catch (error) {\n logger.error(\u0027获取stash列表失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 创建新的stash\n app.post(\u0027/api/stash-save\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { message, includeUntracked, excludeLocked } = req.body;\n \n if (excludeLocked) {\n const lockedFiles = await configManager.getLockedFiles();\n // 包含未跟踪文件,确保状态与 UI 一致\n const { stdout: statusStdout } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--untracked-files=all\u0027], { log: false });\n const changedFiles = statusStdout\n .split(\u0027\\n\u0027)\n .filter(line =\u003e line.trim())\n .map(line =\u003e {\n const match = line.match(/^(..)\\s+(.+)$/);\n if (match) {\n const status = match[1];\n let filename = match[2];\n if (filename.startsWith(\u0027\"\u0027) \u0026\u0026 filename.endsWith(\u0027\"\u0027)) {\n filename = filename.slice(1, -1).replace(/\\\\(.)/g, \u0027$1\u0027);\n }\n return { status, filename };\n }\n return null;\n })\n .filter(Boolean);\n \n const path = (await import(\u0027path\u0027)).default;\n const fs = (await import(\u0027fs\u0027)).default;\n \n // 过滤出未锁定且需要包含在 stash 中的路径\n // 修复:当 includeUntracked === true 且变更项是“新目录”时,不能直接把目录作为 pathspec\n // 否则会把目录里的“锁定文件”一起打入 stash。这里将目录展开为具体文件,并逐个过滤锁定路径。\n const filesToStashSet = new Set();\n for (const item of changedFiles) {\n const { status, filename } = item;\n const normalizedFile = path.normalize(filename);\n \n // 检查是否被锁定\n const isLocked = lockedFiles.some(locked =\u003e {\n const normalizedLocked = path.normalize(locked);\n return normalizedFile === normalizedLocked || normalizedFile.startsWith(normalizedLocked + path.sep);\n });\n \n if (!isLocked) {\n try {\n const fullPath = path.resolve(filename);\n const stats = fs.statSync(fullPath);\n // 1) 已存在的普通文件:直接加入\n if (stats.isFile()) {\n filesToStashSet.add(filename);\n } else if (stats.isDirectory()) {\n // 2) 目录:当勾选了 includeUntracked 时,展开目录下的文件(包含未跟踪和已跟踪修改)\n if (includeUntracked) {\n try {\n // 使用 git 列出该目录下的未跟踪和已修改文件\n const { stdout: listStdout } = await execGitCommand([\u0027ls-files\u0027, \u0027-mo\u0027, \u0027--exclude-standard\u0027, \u0027--\u0027, filename], { log: false });\n const listed = listStdout\n .split(\u0027\\n\u0027)\n .map(l =\u003e l.trim())\n .filter(Boolean)\n // 仅保留该目录下的条目\n .filter(p =\u003e {\n const n = path.normalize(p);\n const base = path.normalize(filename);\n return n === base || n.startsWith(base + path.sep);\n });\n for (const p of listed) {\n const n = path.normalize(p);\n const locked = lockedFiles.some(locked =\u003e {\n const nl = path.normalize(locked);\n return n === nl || n.startsWith(nl + path.sep);\n });\n if (!locked) {\n filesToStashSet.add(p);\n }\n }\n } catch (_) {\n // 如果 git 列举失败,退化为不处理该目录\n }\n }\n }\n } catch (error) {\n // 3) 文件系统不可达的情况\n // 对于已删除的文件(D状态),我们仍然需要包含它们\n if (status.includes(\u0027D\u0027)) {\n filesToStashSet.add(filename);\n }\n // 其他情况(如路径不存在且不是删除状态)则跳过\n }\n }\n }\n \n let filesToStash = Array.from(filesToStashSet);\n if (filesToStash.length === 0) {\n return res.json({ success: false, message: \u0027所有更改都是锁定文件,无需储藏\u0027 });\n }\n \n // 在执行 stash 前进行候选校验:\n // 1) 仍有跟踪差异的文件\n try {\n const { stdout: diffNames } = await execGitCommand([\u0027diff\u0027, \u0027--name-only\u0027, \u0027--\u0027, ...filesToStash], { log: false });\n const trackedChanged = new Set(diffNames.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean));\n \n // 2) 仍为未跟踪的文件(当 includeUntracked 才检查)\n let untrackedExisting = new Set();\n if (includeUntracked) {\n const { stdout: others } = await execGitCommand([\u0027ls-files\u0027, \u0027--others\u0027, \u0027--exclude-standard\u0027, \u0027--\u0027, ...filesToStash], { log: false });\n untrackedExisting = new Set(others.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean));\n }\n \n // 合并有效集合\n const validSet = new Set();\n for (const f of filesToStash) {\n if (trackedChanged.has(f) || untrackedExisting.has(f)) {\n validSet.add(f);\n }\n }\n \n filesToStash = Array.from(validSet);\n } catch (e) {\n // 校验失败不应中断主流程,保守继续使用原集合\n logger.warn(\u0027候选文件有效性校验失败(将继续尝试储藏):\u0027, e?.message || e);\n }\n \n if (filesToStash.length === 0) {\n return res.json({ success: false, message: \u0027没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)\u0027 });\n }\n \n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) stashArgs.push(\u0027-m\u0027, message);\n if (includeUntracked) stashArgs.push(\u0027--include-untracked\u0027);\n if (filesToStash.length \u003e 0) stashArgs.push(\u0027--\u0027, ...filesToStash);\n \n const { stdout } = await execGitCommand(stashArgs);\n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n return res.json({ success: true, message: \u0027成功保存未锁定的工作区更改\u0027, output: stdout });\n }\n \n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) stashArgs.push(\u0027-m\u0027, message);\n if (includeUntracked) stashArgs.push(\u0027--include-untracked\u0027);\n const { stdout } = await execGitCommand(stashArgs);\n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n res.json({ success: true, message: \u0027成功保存工作区更改\u0027, output: stdout });\n } catch (error) {\n // 友好处理:当 Git 返回 \"No valid patches in input\" 时,提示无可储藏更改\n const msg = error?.message || \u0027\u0027;\n if (msg.includes(\u0027No valid patches in input\u0027)) {\n return res.json({ success: false, message: \u0027没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)\u0027 });\n }\n logger.error(\u0027保存stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 保存部分文件的stash\n app.post(\u0027/api/stash-save-partial\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { files, message, includeUntracked } = req.body;\n \n if (!files || !Array.isArray(files) || files.length === 0) {\n return res.json({ success: false, message: \u0027请选择要储藏的文件\u0027 });\n }\n \n // 构建 git stash push 命令\n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) {\n stashArgs.push(\u0027-m\u0027, message);\n }\n if (includeUntracked) {\n stashArgs.push(\u0027--include-untracked\u0027);\n }\n \n // 添加文件列表\n stashArgs.push(\u0027--\u0027, ...files);\n \n const { stdout } = await execGitCommand(stashArgs);\n \n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n \n res.json({\n success: true,\n message: `成功储藏 ${files.length} 个文件`,\n output: stdout\n });\n } catch (error) {\n const msg = error?.message || \u0027\u0027;\n if (msg.includes(\u0027No valid patches in input\u0027)) {\n return res.json({ success: false, message: \u0027没有可储藏的更改\u0027 });\n }\n logger.error(\u0027保存部分stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 应用特定的stash\n app.post(\u0027/api/stash-apply\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, pop } = req.body;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n // 决定是使用apply(保留stash)还是pop(应用后删除stash)\n const command = pop ? [\u0027stash\u0027, \u0027pop\u0027, stashId] : [\u0027stash\u0027, \u0027apply\u0027, stashId];\n \n try {\n const { stdout } = await execGitCommand(command);\n \n res.json({\n success: true,\n message: `成功${pop ? \u0027应用并删除\u0027 : \u0027应用\u0027}stash`,\n output: stdout\n });\n } catch (error) {\n // 检查是否有合并冲突\n if (error.message \u0026\u0026 error.message.includes(\u0027CONFLICT\u0027)) {\n return res.status(409).json({\n success: false,\n hasConflicts: true,\n error: \u0027应用stash时发生冲突,需要手动解决\u0027,\n details: error.message\n });\n }\n throw error;\n }\n } catch (error) {\n logger.error(\u0027应用stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 删除特定的stash\n app.post(\u0027/api/stash-drop\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId } = req.body;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027drop\u0027, stashId]);\n \n res.json({\n success: true,\n message: \u0027成功删除stash\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027删除stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 清空所有stash\n app.post(\u0027/api/stash-clear\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027clear\u0027]);\n \n res.json({\n success: true,\n message: \u0027成功清空所有stash\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027清空stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取stash中的文件列表(包含未跟踪文件)\n app.get(\u0027/api/stash-files\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId } = req.query;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n logger.info(`获取stash文件列表: stashId=${stashId}`);\n \n // 0) 解析出当前 stash 提交及其父提交哈希,避免在 Windows 上使用 ^ 语法\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027; // 当包含未跟踪文件时,第三父才存在\n \n // 1) 跟踪文件的变更列表:父1 与 stash 提交的差异(若无父1则为空)\n let trackedFiles = [];\n if (parent1) {\n const { stdout: trackedOut } = await execGitCommand([\u0027diff\u0027, \u0027--name-only\u0027, parent1, stashCommit], { log: false });\n trackedFiles = trackedOut.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean);\n }\n \n // 2) 未跟踪文件:来自第三父(若存在)\n let untrackedFiles = [];\n if (parent3) {\n const { stdout: untrackedOut } = await execGitCommand([\u0027ls-tree\u0027, \u0027-r\u0027, \u0027--name-only\u0027, parent3], { log: false });\n untrackedFiles = untrackedOut.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean);\n }\n \n // 合并并去重\n const fileSet = new Set([ ...trackedFiles, ...untrackedFiles ]);\n const files = Array.from(fileSet);\n logger.info(`找到${files.length}个stash文件(含未跟踪):`, files);\n \n res.json({\n success: true,\n files\n });\n } catch (error) {\n logger.error(\u0027获取stash文件列表失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `获取stash文件列表失败: ${error.message}`\n });\n }\n }));\n\n // 获取stash中特定文件的差异(包含未跟踪文件)\n app.get(\u0027/api/stash-file-diff\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, file } = req.query;\n \n if (!stashId || !file) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少必要参数\u0027\n });\n }\n \n logger.info(`获取stash文件差异: stashId=${stashId}, file=${file}`);\n \n // 先解析父提交哈希,避免使用 ^ 语法\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027;\n \n // 检查该文件是否来自第三父(未跟踪文件)\n let isFromThirdParent = false;\n if (parent3) {\n try {\n await execGitCommand([\u0027cat-file\u0027, \u0027-e\u0027, `${parent3}:${file}`], { log: false });\n isFromThirdParent = true;\n } catch (_) {\n isFromThirdParent = false;\n }\n }\n \n if (isFromThirdParent) {\n // 未跟踪文件:读取第三父中的内容,构造新增文件的统一diff\n const { stdout: blob } = await execGitCommand([\u0027show\u0027, `${parent3}:${file}`], { log: false });\n \n // 检查文件大小\n const sizeCheck = checkDiffSize(blob, 500);\n if (sizeCheck) {\n return res.json({ success: true, ...sizeCheck });\n }\n \n const lines = blob.endsWith(\u0027\\n\u0027) ? blob.slice(0, -1).split(\u0027\\n\u0027) : blob.split(\u0027\\n\u0027);\n const lineCount = lines.length;\n \n // 检查行数\n if (lineCount \u003e 10000) {\n return res.json({\n success: true,\n diff: `⚠️ 变更内容过大 (${lineCount.toLocaleString()} 行),diff已跳过显示以避免浏览器卡顿。\\n\\n提示:建议使用命令行查看大文件变更。`,\n isLargeFile: true,\n stats: { added: lineCount, deleted: 0, total: lineCount }\n });\n }\n \n const plusLines = lines.map(l =\u003e `+${l}`).join(\u0027\\n\u0027);\n const diffText = [\n `diff --git a/${file} b/${file}`,\n `new file mode 100644`,\n `--- /dev/null`,\n `+++ b/${file}`,\n `@@ -0,0 +${lineCount} @@`,\n `${plusLines}`\n ].join(\u0027\\n\u0027);\n \n return res.json({ success: true, diff: diffText });\n }\n \n // 否则,使用原有方式获取与父1的变更\n // checkShouldSkipDiff 接受字符串命令用于日志展示,这里只用于大小判断;\n // 走 execGitCommand 时用 argv 数组,避免 Windows 下 cmd.exe 拼引号被破坏。\n const diffCommandForCheck = `git show ${stashCommit} -- \"${file}\"`;\n const diffCommandArgs = [\u0027show\u0027, stashCommit, \u0027--\u0027, file];\n \n // 使用优化的检查函数\n const skipCheck = await checkShouldSkipDiff(file, diffCommandForCheck);\n if (skipCheck.shouldSkip) {\n return res.json({\n success: true,\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n const { stdout } = await execGitCommand(diffCommandArgs);\n \n logger.info(`获取到差异内容,长度: ${stdout.length}`);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json({ success: true, ...sizeCheck });\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ success: true, diff: stdout, stats });\n } catch (error) {\n logger.error(\u0027获取stash文件差异失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `获取stash文件差异失败: ${error.message}`\n });\n }\n }));\n\n // 获取stash中特定文件的完整内容对比(原始 vs 储藏后)\n app.get(\u0027/api/stash-file-compare\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, file } = req.query;\n \n if (!stashId || !file) {\n throw new HttpError(400, \u0027缺少必要参数\u0027);\n }\n \n // 解析父提交哈希\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027;\n \n // 检查是否为未跟踪文件(来自第三父)\n let isFromThirdParent = false;\n if (parent3) {\n try {\n await execGitCommand([\u0027cat-file\u0027, \u0027-e\u0027, `${parent3}:${file}`], { log: false });\n isFromThirdParent = true;\n } catch (_) {\n isFromThirdParent = false;\n }\n }\n \n // 获取原始内容(储藏前,来自 parent1)\n let original = \u0027\u0027;\n if (!isFromThirdParent \u0026\u0026 parent1) {\n try {\n const { stdout: origOut } = await execGitCommand([\u0027show\u0027, `${parent1}:${file}`], { log: false });\n original = origOut ?? \u0027\u0027;\n } catch (_) {\n original = \u0027\u0027; // 文件在储藏前不存在\n }\n }\n \n // 获取储藏后的内容\n let modified = \u0027\u0027;\n const modRef = isFromThirdParent ? parent3 : stashCommit;\n if (modRef) {\n try {\n const { stdout: modOut } = await execGitCommand([\u0027show\u0027, `${modRef}:${file}`], { log: false });\n modified = modOut ?? \u0027\u0027;\n } catch (_) {\n modified = \u0027\u0027; // 文件已被删除\n }\n }\n \n res.json({ success: true, original, modified });\n } catch (error) {\n logger.error(\u0027获取stash文件对比失败:\u0027, error);\n res.status(500).json({ success: false, error: `获取stash文件对比失败: ${error.message}` });\n }\n }));\n}\n","structuredPatch":[{"oldStart":13,"oldLines":7,"newStart":13,"newLines":7,"lines":[" // limitations under the License."," //"," import { createDiffHelpers } from \u0027./diffUtils.js\u0027;","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027../../utils/logger.js\u0027"," import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;"," "," export function registerGitStashRoutes({ app, execGitCommand, configManager }) {"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f4d","duration_ms":6,"timestamp":"2026-06-27 09:49:18"}
6
- {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\tags.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027../../utils/logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\tags.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027../../utils/logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027\n\nimport logger from \u0027../utils/logger.js\u0027\n\nexport function registerGitTagRoutes({ app, execGitCommand, clearCommandHistory }) {\n // ============ Git Tag 相关接口 ============\n\n // 创建标签\n app.post(\u0027/api/create-tag\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { tagName, message, type, commit } = req.body;\n \n if (!tagName) {\n throw new HttpError(400, \u0027缺少标签名称\u0027);\n }\n \n const tagArgs = [\u0027tag\u0027];\n \n if (type === \u0027annotated\u0027) {\n // 附注标签\n if (!message) {\n throw new HttpError(400, \u0027附注标签需要提供说明信息\u0027);\n }\n tagArgs.push(\u0027-a\u0027, tagName, \u0027-m\u0027, message);\n } else {\n // 轻量标签\n tagArgs.push(tagName);\n }\n \n // 如果指定了commit,添加到命令中\n if (commit \u0026\u0026 commit.trim()) {\n tagArgs.push(commit.trim());\n }\n \n const { stdout } = await execGitCommand(tagArgs);\n \n res.json({\n success: true,\n message: \u0027标签创建成功\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027创建标签失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取标签列表\n app.get(\u0027/api/list-tags\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n // 使用 git tag -n --format 获取详细信息\n const { stdout } = await execGitCommand(\n [\u0027tag\u0027, \u0027-n\u0027, \u0027--format=%(refname:short)|%(objectname:short)|%(creatordate:iso8601)|%(subject)\u0027]\n );\n \n if (!stdout.trim()) {\n return res.json({ success: true, tags: [] });\n }\n \n const tags = stdout.trim().split(\u0027\\n\u0027).map(line =\u003e {\n const [name, commit, date, message] = line.split(\u0027|\u0027);\n return {\n name: name || \u0027\u0027,\n commit: commit || \u0027\u0027,\n date: date || \u0027\u0027,\n message: message || \u0027\u0027,\n type: \u0027lightweight\u0027 // 默认为轻量标签\n };\n });\n \n // 检测哪些是附注标签\n for (const tag of tags) {\n try {\n const { stdout: typeCheck } = await execGitCommand(\n [\u0027cat-file\u0027, \u0027-t\u0027, tag.name],\n { log: false }\n );\n if (typeCheck.trim() === \u0027tag\u0027) {\n tag.type = \u0027annotated\u0027;\n }\n } catch (error) {\n // 忽略错误,保持默认值\n }\n }\n \n res.json({ success: true, tags });\n } catch (error) {\n logger.error(\u0027获取标签列表失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 推送标签到远程\n app.post(\u0027/api/push-tag\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { tagName } = req.body;\n \n if (!tagName) {\n throw new HttpError(400, \u0027缺少标签名称\u0027);\n }\n \n const { stdout } = await execGitCommand([\u0027push\u0027, \u0027origin\u0027, tagName]);\n \n res.json({\n success: true,\n message: \u0027标签推送成功\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027推送标签失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 推送所有标签到远程\n app.post(\u0027/api/push-all-tags\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027push\u0027, \u0027origin\u0027, \u0027--tags\u0027]);\n \n res.json({\n success: true,\n message: \u0027所有标签推送成功\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027推送所有标签失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 删除标签\n app.post(\u0027/api/delete-tag\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { tagName } = req.body;\n \n if (!tagName) {\n throw new HttpError(400, \u0027缺少标签名称\u0027);\n }\n \n const { stdout } = await execGitCommand([\u0027tag\u0027, \u0027-d\u0027, tagName]);\n \n res.json({\n success: true,\n message: \u0027标签删除成功\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027删除标签失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 添加命令历史的清空API\n app.post(\u0027/api/clear-command-history\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const result = clearCommandHistory();\n res.json({ success: result });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n}\n","structuredPatch":[{"oldStart":14,"oldLines":7,"newStart":14,"newLines":7,"lines":[" //"," import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027"," ","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027../../utils/logger.js\u0027"," "," export function registerGitTagRoutes({ app, execGitCommand, clearCommandHistory }) {"," // ============ Git Tag 相关接口 ============"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f56","duration_ms":4,"timestamp":"2026-06-27 09:49:18"}
7
- {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diff.js","old_string":"import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","new_string":"import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diff.js","oldString":"import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","newString":"import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport fs from \u0027fs/promises\u0027;\nimport logger from \u0027../../utils/logger.js\u0027\nimport { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;\n\nimport { createDiffHelpers } from \u0027./diffUtils.js\u0027;\n\nimport { ensureWithinCwd } from \u0027../../utils/pathGuard.js\u0027;\n\n/**\n * SEC-PATH-1: resolve user path to be inside process.cwd(), returns null on escape\n * @param {string} userPath\n * @returns {Promise\u003cstring|null\u003e}\n */\nasync function safePathInProject(userPath) {\n if (typeof userPath !== \u0027string\u0027 || !userPath) return null;\n if (/[\\x00-\\x1f]/.test(userPath)) return null;\n const cwd = process.cwd();\n const result = await ensureWithinCwd(userPath, cwd);\n return result ? result.safePath : null;\n}\n\nexport function registerGitDiffRoutes({\n app,\n execGitCommand\n}) {\n const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });\n\n const skipExtensions = /\\.(min\\.js|umd\\.cjs|bundle\\.js|dist\\.js|prod\\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;\n const maxBytes = 1024 * 1024;\n\n // 获取文件差异\n app.get(\u0027/api/diff\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n \n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n \n const diffArgs = [\u0027diff\u0027, \u0027--\u0027, filePath];\n \n // 使用优化的检查函数(diffCommand 用于内部 numstat 检测的字符串转换,\n // 实际走 git 时用 argv 数组,避免 Windows cmd.exe 拼引号问题)\n const skipCheck = await checkShouldSkipDiff(filePath, `git diff -- \"${filePath}\"`);\n if (skipCheck.shouldSkip) {\n return res.json({\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n // 执行git diff命令获取文件差异\n const { stdout } = await execGitCommand(diffArgs);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json(sizeCheck);\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ diff: stdout, stats });\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n // 获取已暂存文件差异\n app.get(\u0027/api/diff-cached\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n \n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n \n const diffArgs = [\u0027diff\u0027, \u0027--cached\u0027, \u0027--\u0027, filePath];\n \n // 使用优化的检查函数\n const skipCheck = await checkShouldSkipDiff(filePath, `git diff --cached -- \"${filePath}\"`);\n if (skipCheck.shouldSkip) {\n return res.json({\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n // 执行git diff --cached命令获取已暂存文件差异\n const { stdout } = await execGitCommand(diffArgs);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json(sizeCheck);\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ diff: stdout, stats });\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n\n // 获取全量 diff(git diff HEAD,含已暂存与未暂存的所有变更)\n app.get(\u0027/api/diff-head\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027diff\u0027, \u0027HEAD\u0027]);\n const MAX = 500 * 1024;\n const content = stdout.length \u003e MAX\n ? stdout.slice(0, MAX) + \u0027\\n\\n[内容过大,已截断]\u0027\n : stdout;\n res.json({ success: true, diff: content });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取文件内容 (用于未跟踪文件)\n app.get(\u0027/api/file-content\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n\n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内,越界返回 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n try {\n // 二进制/产物文件:直接告知前端 isBinary,不读取内容\n if (skipExtensions.test(safeFilePath)) {\n const isImage = /\\.(png|jpg|jpeg|gif|webp|bmp|ico|svg)$/i.test(safeFilePath);\n return res.json({\n success: true,\n isBinary: true,\n isImage,\n content: isImage\n ? \u0027⚠️ 该文件是图片,建议在预览中查看。\u0027\n : \u0027⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。\u0027\n });\n }\n\n // 读取文件内容(走 safePath,不是 user input)\n const content = await fs.readFile(safeFilePath, \u0027utf8\u0027);\n res.json({ success: true, content });\n } catch (readError) {\n res.status(500).json({ success: false, error: `无法读取文件: ${readError.message}` });\n }\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n\n app.get(\u0027/api/git-file-content\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n const rev = req.query.rev;\n\n if (!filePath || !rev) {\n throw new HttpError(400, \u0027缺少必要参数\u0027);\n }\n\n // SEC-PATH-1:路径必须在 cwd 内,越界返回 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n if (skipExtensions.test(safeFilePath)) {\n return res.json({\n success: true,\n isBinary: true,\n content: \u0027⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。\u0027\n });\n }\n\n const r = String(rev);\n // 用 safeFilePath 而不是 user input,防注入\n const spec = r === \u0027:\u0027 ? `:${safeFilePath}` : `${r}:${safeFilePath}`;\n\n let sizeBytes = 0;\n try {\n const { stdout: sizeOut } = await execGitCommand([\u0027cat-file\u0027, \u0027-s\u0027, spec], { log: false });\n sizeBytes = parseInt(String(sizeOut).trim(), 10) || 0;\n } catch (e) {\n return res.json({ success: true, notFound: true, content: \u0027\u0027 });\n }\n\n if (sizeBytes \u003e maxBytes) {\n return res.json({\n success: true,\n isLargeFile: true,\n size: sizeBytes,\n content: `⚠️ 文件内容过大 (${(sizeBytes / 1024).toFixed(1)} KB),已跳过显示以避免浏览器卡顿。`\n });\n }\n\n const { stdout } = await execGitCommand([\u0027show\u0027, spec], { log: false });\n return res.json({ success: true, content: stdout ?? \u0027\u0027 });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 解决冲突:保存解决后的文件内容\n app.post(\u0027/api/resolve-conflict\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { filePath, content } = req.body;\n\n if (!filePath) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件路径参数\u0027\n });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内,越界 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n if (content === undefined) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件内容参数\u0027\n });\n }\n\n try {\n // 写入解决后的内容到文件(走 safePath)\n await fs.writeFile(safeFilePath, content, \u0027utf8\u0027);\n\n res.json({\n success: true,\n message: \u0027冲突已解决,文件已更新\u0027\n });\n } catch (writeError) {\n res.status(500).json({\n success: false,\n error: `保存文件失败: ${writeError.message}`\n });\n }\n } catch (error) {\n logger.error(\u0027解决冲突失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `解决冲突失败: ${error.message}`\n });\n }\n }));\n\n // 撤回文件修改\n app.post(\u0027/api/revert_file\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { filePath } = req.body;\n\n if (!filePath) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件路径参数\u0027\n });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n // 检查文件状态:未跟踪文件需要删除,修改文件需要恢复\n const { stdout: statusOutput } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--\u0027, safeFilePath]);\n\n // 未跟踪的文件 (??), 需要删除它\n if (statusOutput.startsWith(\u0027??\u0027)) {\n try {\n await fs.unlink(safeFilePath);\n return res.json({ success: true, message: \u0027未跟踪的文件已删除\u0027 });\n } catch (error) {\n return res.status(500).json({\n success: false,\n error: `删除文件失败: ${error.message}`\n });\n }\n }\n // 已暂存的文件,先取消暂存\n else if (statusOutput.startsWith(\u0027A \u0027) || statusOutput.startsWith(\u0027M \u0027) || statusOutput.startsWith(\u0027D \u0027)) {\n // 先取消暂存\n await execGitCommand([\u0027reset\u0027, \u0027HEAD\u0027, \u0027--\u0027, safeFilePath]);\n }\n\n // 已修改文件,取消所有本地修改\n if (statusOutput) {\n await execGitCommand([\u0027checkout\u0027, \u0027--\u0027, safeFilePath]);\n return res.json({ success: true, message: \u0027文件修改已撤回\u0027 });\n } else {\n return res.status(400).json({\n success: false,\n error: \u0027文件没有修改或不存在\u0027\n });\n }\n } catch (error) {\n logger.error(\u0027撤回文件修改失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `撤回文件修改失败: ${error.message}`\n });\n }\n }));\n\n // 批量撤回文件修改(未跟踪删除,已修改 checkout 还原)\n // body: { filePaths: string[] }\n // 返回: { success, count, results: [{ path, success, error? }] }\n app.post(\u0027/api/revert_files\u0027, asyncRoute(async (req, res) =\u003e {\n const filePaths = Array.isArray(req.body?.filePaths) ? req.body.filePaths : []\n if (filePaths.length === 0) {\n return res.status(400).json({ success: false, error: \u0027缺少文件路径参数\u0027 })\n }\n\n // 批量大小限制,防止 DoS\n const MAX_BATCH = 200;\n if (filePaths.length \u003e MAX_BATCH) {\n return res.status(400).json({ success: false, error: `批量大小不能超过 ${MAX_BATCH}` });\n }\n\n const results = []\n let successCount = 0\n\n for (const filePath of filePaths) {\n try {\n // SEC-PATH-1:每个 path 都校验在 cwd 内,越界直接报错\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n results.push({ path: filePath, success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n continue;\n }\n\n // 检查文件状态:未跟踪 ??、已暂存 A/M/D、已修改(空状态会返回空字符串)\n const { stdout: statusOutput } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--\u0027, safeFilePath])\n\n // 未跟踪的文件 (??) → 直接删除\n if (statusOutput.startsWith(\u0027??\u0027)) {\n try {\n await fs.unlink(safeFilePath)\n results.push({ path: filePath, success: true, message: \u0027未跟踪的文件已删除\u0027 })\n successCount++\n continue\n } catch (err) {\n results.push({ path: filePath, success: false, error: `删除文件失败: ${err?.message || err}` })\n continue\n }\n }\n\n // 已暂存的文件,先取消暂存(不影响工作区)\n if (statusOutput.startsWith(\u0027A \u0027) || statusOutput.startsWith(\u0027M \u0027) || statusOutput.startsWith(\u0027D \u0027)) {\n await execGitCommand([\u0027reset\u0027, \u0027HEAD\u0027, \u0027--\u0027, safeFilePath])\n }\n\n // 已修改文件:丢弃工作区修改\n if (statusOutput) {\n await execGitCommand([\u0027checkout\u0027, \u0027--\u0027, safeFilePath])\n results.push({ path: filePath, success: true, message: \u0027文件修改已撤回\u0027 })\n successCount++\n } else {\n // 文件已无修改(可能在并发中被处理掉了)\n results.push({ path: filePath, success: true, message: \u0027文件无修改\u0027 })\n successCount++\n }\n } catch (err) {\n results.push({ path: filePath, success: false, error: err?.message || String(err) })\n }\n }\n\n res.json({\n success: true,\n count: filePaths.length,\n successCount,\n results\n })\n }));\n}\n","structuredPatch":[{"oldStart":14,"oldLines":7,"newStart":14,"newLines":7,"lines":[" //"," import fs from \u0027fs/promises\u0027;"," import logger from \u0027../../utils/logger.js\u0027","-import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","+import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;"," "," import { createDiffHelpers } from \u0027./diffUtils.js\u0027;"," "]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c4c1bc7c6396cbac0e","duration_ms":11,"timestamp":"2026-06-27 09:49:47"}
8
- {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\stash.js","old_string":"import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","new_string":"import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\stash.js","oldString":"import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","newString":"import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport { createDiffHelpers } from \u0027./diffUtils.js\u0027;\nimport logger from \u0027../../utils/logger.js\u0027\nimport { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;\n\nexport function registerGitStashRoutes({ app, execGitCommand, configManager }) {\n const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });\n\n // 获取stash列表\n app.get(\u0027/api/stash-list\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027list\u0027]);\n \n // 解析stash列表\n const stashList = stdout.split(\u0027\\n\u0027)\n .filter(Boolean)\n .map(line =\u003e {\n // 尝试解析stash行,格式类似: stash@{0}: WIP on branch: commit message\n const match = line.match(/^(stash@\\{\\d+\\}): (.+)$/);\n if (match) {\n return {\n id: match[1],\n description: match[2]\n };\n }\n return null;\n })\n .filter(item =\u003e item !== null);\n \n res.json({ success: true, stashes: stashList });\n } catch (error) {\n logger.error(\u0027获取stash列表失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 创建新的stash\n app.post(\u0027/api/stash-save\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { message, includeUntracked, excludeLocked } = req.body;\n \n if (excludeLocked) {\n const lockedFiles = await configManager.getLockedFiles();\n // 包含未跟踪文件,确保状态与 UI 一致\n const { stdout: statusStdout } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--untracked-files=all\u0027], { log: false });\n const changedFiles = statusStdout\n .split(\u0027\\n\u0027)\n .filter(line =\u003e line.trim())\n .map(line =\u003e {\n const match = line.match(/^(..)\\s+(.+)$/);\n if (match) {\n const status = match[1];\n let filename = match[2];\n if (filename.startsWith(\u0027\"\u0027) \u0026\u0026 filename.endsWith(\u0027\"\u0027)) {\n filename = filename.slice(1, -1).replace(/\\\\(.)/g, \u0027$1\u0027);\n }\n return { status, filename };\n }\n return null;\n })\n .filter(Boolean);\n \n const path = (await import(\u0027path\u0027)).default;\n const fs = (await import(\u0027fs\u0027)).default;\n \n // 过滤出未锁定且需要包含在 stash 中的路径\n // 修复:当 includeUntracked === true 且变更项是“新目录”时,不能直接把目录作为 pathspec\n // 否则会把目录里的“锁定文件”一起打入 stash。这里将目录展开为具体文件,并逐个过滤锁定路径。\n const filesToStashSet = new Set();\n for (const item of changedFiles) {\n const { status, filename } = item;\n const normalizedFile = path.normalize(filename);\n \n // 检查是否被锁定\n const isLocked = lockedFiles.some(locked =\u003e {\n const normalizedLocked = path.normalize(locked);\n return normalizedFile === normalizedLocked || normalizedFile.startsWith(normalizedLocked + path.sep);\n });\n \n if (!isLocked) {\n try {\n const fullPath = path.resolve(filename);\n const stats = fs.statSync(fullPath);\n // 1) 已存在的普通文件:直接加入\n if (stats.isFile()) {\n filesToStashSet.add(filename);\n } else if (stats.isDirectory()) {\n // 2) 目录:当勾选了 includeUntracked 时,展开目录下的文件(包含未跟踪和已跟踪修改)\n if (includeUntracked) {\n try {\n // 使用 git 列出该目录下的未跟踪和已修改文件\n const { stdout: listStdout } = await execGitCommand([\u0027ls-files\u0027, \u0027-mo\u0027, \u0027--exclude-standard\u0027, \u0027--\u0027, filename], { log: false });\n const listed = listStdout\n .split(\u0027\\n\u0027)\n .map(l =\u003e l.trim())\n .filter(Boolean)\n // 仅保留该目录下的条目\n .filter(p =\u003e {\n const n = path.normalize(p);\n const base = path.normalize(filename);\n return n === base || n.startsWith(base + path.sep);\n });\n for (const p of listed) {\n const n = path.normalize(p);\n const locked = lockedFiles.some(locked =\u003e {\n const nl = path.normalize(locked);\n return n === nl || n.startsWith(nl + path.sep);\n });\n if (!locked) {\n filesToStashSet.add(p);\n }\n }\n } catch (_) {\n // 如果 git 列举失败,退化为不处理该目录\n }\n }\n }\n } catch (error) {\n // 3) 文件系统不可达的情况\n // 对于已删除的文件(D状态),我们仍然需要包含它们\n if (status.includes(\u0027D\u0027)) {\n filesToStashSet.add(filename);\n }\n // 其他情况(如路径不存在且不是删除状态)则跳过\n }\n }\n }\n \n let filesToStash = Array.from(filesToStashSet);\n if (filesToStash.length === 0) {\n return res.json({ success: false, message: \u0027所有更改都是锁定文件,无需储藏\u0027 });\n }\n \n // 在执行 stash 前进行候选校验:\n // 1) 仍有跟踪差异的文件\n try {\n const { stdout: diffNames } = await execGitCommand([\u0027diff\u0027, \u0027--name-only\u0027, \u0027--\u0027, ...filesToStash], { log: false });\n const trackedChanged = new Set(diffNames.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean));\n \n // 2) 仍为未跟踪的文件(当 includeUntracked 才检查)\n let untrackedExisting = new Set();\n if (includeUntracked) {\n const { stdout: others } = await execGitCommand([\u0027ls-files\u0027, \u0027--others\u0027, \u0027--exclude-standard\u0027, \u0027--\u0027, ...filesToStash], { log: false });\n untrackedExisting = new Set(others.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean));\n }\n \n // 合并有效集合\n const validSet = new Set();\n for (const f of filesToStash) {\n if (trackedChanged.has(f) || untrackedExisting.has(f)) {\n validSet.add(f);\n }\n }\n \n filesToStash = Array.from(validSet);\n } catch (e) {\n // 校验失败不应中断主流程,保守继续使用原集合\n logger.warn(\u0027候选文件有效性校验失败(将继续尝试储藏):\u0027, e?.message || e);\n }\n \n if (filesToStash.length === 0) {\n return res.json({ success: false, message: \u0027没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)\u0027 });\n }\n \n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) stashArgs.push(\u0027-m\u0027, message);\n if (includeUntracked) stashArgs.push(\u0027--include-untracked\u0027);\n if (filesToStash.length \u003e 0) stashArgs.push(\u0027--\u0027, ...filesToStash);\n \n const { stdout } = await execGitCommand(stashArgs);\n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n return res.json({ success: true, message: \u0027成功保存未锁定的工作区更改\u0027, output: stdout });\n }\n \n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) stashArgs.push(\u0027-m\u0027, message);\n if (includeUntracked) stashArgs.push(\u0027--include-untracked\u0027);\n const { stdout } = await execGitCommand(stashArgs);\n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n res.json({ success: true, message: \u0027成功保存工作区更改\u0027, output: stdout });\n } catch (error) {\n // 友好处理:当 Git 返回 \"No valid patches in input\" 时,提示无可储藏更改\n const msg = error?.message || \u0027\u0027;\n if (msg.includes(\u0027No valid patches in input\u0027)) {\n return res.json({ success: false, message: \u0027没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)\u0027 });\n }\n logger.error(\u0027保存stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 保存部分文件的stash\n app.post(\u0027/api/stash-save-partial\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { files, message, includeUntracked } = req.body;\n \n if (!files || !Array.isArray(files) || files.length === 0) {\n return res.json({ success: false, message: \u0027请选择要储藏的文件\u0027 });\n }\n \n // 构建 git stash push 命令\n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) {\n stashArgs.push(\u0027-m\u0027, message);\n }\n if (includeUntracked) {\n stashArgs.push(\u0027--include-untracked\u0027);\n }\n \n // 添加文件列表\n stashArgs.push(\u0027--\u0027, ...files);\n \n const { stdout } = await execGitCommand(stashArgs);\n \n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n \n res.json({\n success: true,\n message: `成功储藏 ${files.length} 个文件`,\n output: stdout\n });\n } catch (error) {\n const msg = error?.message || \u0027\u0027;\n if (msg.includes(\u0027No valid patches in input\u0027)) {\n return res.json({ success: false, message: \u0027没有可储藏的更改\u0027 });\n }\n logger.error(\u0027保存部分stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 应用特定的stash\n app.post(\u0027/api/stash-apply\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, pop } = req.body;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n // 决定是使用apply(保留stash)还是pop(应用后删除stash)\n const command = pop ? [\u0027stash\u0027, \u0027pop\u0027, stashId] : [\u0027stash\u0027, \u0027apply\u0027, stashId];\n \n try {\n const { stdout } = await execGitCommand(command);\n \n res.json({\n success: true,\n message: `成功${pop ? \u0027应用并删除\u0027 : \u0027应用\u0027}stash`,\n output: stdout\n });\n } catch (error) {\n // 检查是否有合并冲突\n if (error.message \u0026\u0026 error.message.includes(\u0027CONFLICT\u0027)) {\n return res.status(409).json({\n success: false,\n hasConflicts: true,\n error: \u0027应用stash时发生冲突,需要手动解决\u0027,\n details: error.message\n });\n }\n throw error;\n }\n } catch (error) {\n logger.error(\u0027应用stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 删除特定的stash\n app.post(\u0027/api/stash-drop\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId } = req.body;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027drop\u0027, stashId]);\n \n res.json({\n success: true,\n message: \u0027成功删除stash\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027删除stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 清空所有stash\n app.post(\u0027/api/stash-clear\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027clear\u0027]);\n \n res.json({\n success: true,\n message: \u0027成功清空所有stash\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027清空stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取stash中的文件列表(包含未跟踪文件)\n app.get(\u0027/api/stash-files\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId } = req.query;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n logger.info(`获取stash文件列表: stashId=${stashId}`);\n \n // 0) 解析出当前 stash 提交及其父提交哈希,避免在 Windows 上使用 ^ 语法\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027; // 当包含未跟踪文件时,第三父才存在\n \n // 1) 跟踪文件的变更列表:父1 与 stash 提交的差异(若无父1则为空)\n let trackedFiles = [];\n if (parent1) {\n const { stdout: trackedOut } = await execGitCommand([\u0027diff\u0027, \u0027--name-only\u0027, parent1, stashCommit], { log: false });\n trackedFiles = trackedOut.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean);\n }\n \n // 2) 未跟踪文件:来自第三父(若存在)\n let untrackedFiles = [];\n if (parent3) {\n const { stdout: untrackedOut } = await execGitCommand([\u0027ls-tree\u0027, \u0027-r\u0027, \u0027--name-only\u0027, parent3], { log: false });\n untrackedFiles = untrackedOut.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean);\n }\n \n // 合并并去重\n const fileSet = new Set([ ...trackedFiles, ...untrackedFiles ]);\n const files = Array.from(fileSet);\n logger.info(`找到${files.length}个stash文件(含未跟踪):`, files);\n \n res.json({\n success: true,\n files\n });\n } catch (error) {\n logger.error(\u0027获取stash文件列表失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `获取stash文件列表失败: ${error.message}`\n });\n }\n }));\n\n // 获取stash中特定文件的差异(包含未跟踪文件)\n app.get(\u0027/api/stash-file-diff\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, file } = req.query;\n \n if (!stashId || !file) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少必要参数\u0027\n });\n }\n \n logger.info(`获取stash文件差异: stashId=${stashId}, file=${file}`);\n \n // 先解析父提交哈希,避免使用 ^ 语法\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027;\n \n // 检查该文件是否来自第三父(未跟踪文件)\n let isFromThirdParent = false;\n if (parent3) {\n try {\n await execGitCommand([\u0027cat-file\u0027, \u0027-e\u0027, `${parent3}:${file}`], { log: false });\n isFromThirdParent = true;\n } catch (_) {\n isFromThirdParent = false;\n }\n }\n \n if (isFromThirdParent) {\n // 未跟踪文件:读取第三父中的内容,构造新增文件的统一diff\n const { stdout: blob } = await execGitCommand([\u0027show\u0027, `${parent3}:${file}`], { log: false });\n \n // 检查文件大小\n const sizeCheck = checkDiffSize(blob, 500);\n if (sizeCheck) {\n return res.json({ success: true, ...sizeCheck });\n }\n \n const lines = blob.endsWith(\u0027\\n\u0027) ? blob.slice(0, -1).split(\u0027\\n\u0027) : blob.split(\u0027\\n\u0027);\n const lineCount = lines.length;\n \n // 检查行数\n if (lineCount \u003e 10000) {\n return res.json({\n success: true,\n diff: `⚠️ 变更内容过大 (${lineCount.toLocaleString()} 行),diff已跳过显示以避免浏览器卡顿。\\n\\n提示:建议使用命令行查看大文件变更。`,\n isLargeFile: true,\n stats: { added: lineCount, deleted: 0, total: lineCount }\n });\n }\n \n const plusLines = lines.map(l =\u003e `+${l}`).join(\u0027\\n\u0027);\n const diffText = [\n `diff --git a/${file} b/${file}`,\n `new file mode 100644`,\n `--- /dev/null`,\n `+++ b/${file}`,\n `@@ -0,0 +${lineCount} @@`,\n `${plusLines}`\n ].join(\u0027\\n\u0027);\n \n return res.json({ success: true, diff: diffText });\n }\n \n // 否则,使用原有方式获取与父1的变更\n // checkShouldSkipDiff 接受字符串命令用于日志展示,这里只用于大小判断;\n // 走 execGitCommand 时用 argv 数组,避免 Windows 下 cmd.exe 拼引号被破坏。\n const diffCommandForCheck = `git show ${stashCommit} -- \"${file}\"`;\n const diffCommandArgs = [\u0027show\u0027, stashCommit, \u0027--\u0027, file];\n \n // 使用优化的检查函数\n const skipCheck = await checkShouldSkipDiff(file, diffCommandForCheck);\n if (skipCheck.shouldSkip) {\n return res.json({\n success: true,\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n const { stdout } = await execGitCommand(diffCommandArgs);\n \n logger.info(`获取到差异内容,长度: ${stdout.length}`);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json({ success: true, ...sizeCheck });\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ success: true, diff: stdout, stats });\n } catch (error) {\n logger.error(\u0027获取stash文件差异失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `获取stash文件差异失败: ${error.message}`\n });\n }\n }));\n\n // 获取stash中特定文件的完整内容对比(原始 vs 储藏后)\n app.get(\u0027/api/stash-file-compare\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, file } = req.query;\n \n if (!stashId || !file) {\n throw new HttpError(400, \u0027缺少必要参数\u0027);\n }\n \n // 解析父提交哈希\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027;\n \n // 检查是否为未跟踪文件(来自第三父)\n let isFromThirdParent = false;\n if (parent3) {\n try {\n await execGitCommand([\u0027cat-file\u0027, \u0027-e\u0027, `${parent3}:${file}`], { log: false });\n isFromThirdParent = true;\n } catch (_) {\n isFromThirdParent = false;\n }\n }\n \n // 获取原始内容(储藏前,来自 parent1)\n let original = \u0027\u0027;\n if (!isFromThirdParent \u0026\u0026 parent1) {\n try {\n const { stdout: origOut } = await execGitCommand([\u0027show\u0027, `${parent1}:${file}`], { log: false });\n original = origOut ?? \u0027\u0027;\n } catch (_) {\n original = \u0027\u0027; // 文件在储藏前不存在\n }\n }\n \n // 获取储藏后的内容\n let modified = \u0027\u0027;\n const modRef = isFromThirdParent ? parent3 : stashCommit;\n if (modRef) {\n try {\n const { stdout: modOut } = await execGitCommand([\u0027show\u0027, `${modRef}:${file}`], { log: false });\n modified = modOut ?? \u0027\u0027;\n } catch (_) {\n modified = \u0027\u0027; // 文件已被删除\n }\n }\n \n res.json({ success: true, original, modified });\n } catch (error) {\n logger.error(\u0027获取stash文件对比失败:\u0027, error);\n res.status(500).json({ success: false, error: `获取stash文件对比失败: ${error.message}` });\n }\n }));\n}\n","structuredPatch":[{"oldStart":14,"oldLines":7,"newStart":14,"newLines":7,"lines":[" //"," import { createDiffHelpers } from \u0027./diffUtils.js\u0027;"," import logger from \u0027../../utils/logger.js\u0027","-import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","+import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;"," "," export function registerGitStashRoutes({ app, execGitCommand, configManager }) {"," const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c4c1bc7c6396cbac12","duration_ms":6,"timestamp":"2026-06-27 09:49:48"}