dsh-asuta-worktree-control 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/README_EN.md +61 -0
- package/client.js +790 -0
- package/cordis.patch.yml +4 -0
- package/git.js +1011 -0
- package/icon.svg +7 -0
- package/index.js +287 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/terminal-client.mjs +84 -0
- package/terminal-input.js +24 -0
- package/terminal-jobs.js +170 -0
package/client.js
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-asuta-worktree-control',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react'), h = React.createElement
|
|
5
|
+
const { createPortal } = require('react-dom')
|
|
6
|
+
const TAB_ID = 'dsh-asuta-worktree-control/panel', KIND = 'worktree-control'
|
|
7
|
+
const CSS = [
|
|
8
|
+
'.wt-panel{height:100%;min-height:0;display:flex;flex-direction:column;color:var(--dsw-alias-label-primary);font:13px/1.5 var(--dsw-font-family),system-ui,sans-serif}',
|
|
9
|
+
'.wt-panel *{box-sizing:border-box}.wt-head{padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-l2);display:flex;align-items:center;gap:8px}.wt-head strong{flex:1}.wt-tabs{display:flex;gap:6px;padding:9px 12px;border-bottom:1px solid var(--dsw-alias-border-l2)}',
|
|
10
|
+
'.wt-panel button,.wt-panel input,.wt-panel select,.wt-panel textarea{font:inherit;color:inherit}.wt-panel button{cursor:pointer}.wt-panel button:focus-visible,.wt-panel input:focus-visible,.wt-panel select:focus-visible,.wt-panel textarea:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}',
|
|
11
|
+
'.wt-fork-anchor{position:relative;-webkit-app-region:no-drag}.wt-fork-menu{position:absolute;left:0;bottom:calc(100% + 8px);z-index:80;box-sizing:border-box;width:min(330px,calc(100vw - 32px));padding:14px;border:1px solid var(--dsw-alias-border-l2,#444);border-radius:14px;background:var(--dsw-specific-sidebar-fill,#292929);box-shadow:0 16px 42px #0006;color:var(--dsw-alias-label-primary,#eee);font:13px/1.5 var(--dsw-font-family,system-ui),sans-serif;-webkit-app-region:no-drag}.wt-fork-menu strong{display:block;margin:0 0 8px;font-size:15px}.wt-fork-option{display:block;width:100%;padding:9px 10px;border:0;border-radius:9px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}.wt-fork-option:hover:not(:disabled),.wt-fork-option:focus-visible{background:var(--dsw-alias-interactive-bg-hover,#ffffff12)}.wt-fork-option:disabled{opacity:.55;cursor:default}.wt-fork-option small{display:block;color:var(--dsw-alias-label-secondary,#aaa)}.wt-fork-menu .wt-error{margin:8px 4px 0}',
|
|
12
|
+
'.wt-tabs button,.wt-btn,.wt-select,.wt-input{border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;padding:6px 9px}.wt-tabs button[aria-selected=true]{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-state-business-primary)}.wt-btn:hover,.wt-tabs button:hover{background:var(--dsw-alias-interactive-bg-hover)}.wt-btn:disabled{opacity:.5;cursor:default}',
|
|
13
|
+
'.wt-scroll{overflow:auto;min-height:0;flex:1;padding:12px 14px}.wt-muted{color:var(--dsw-alias-label-secondary);font-size:12px;overflow-wrap:anywhere}.wt-error{color:#e88e82;white-space:pre-wrap;overflow-wrap:anywhere}.wt-ok{color:#75c788;white-space:pre-wrap;overflow-wrap:anywhere}',
|
|
14
|
+
'.wt-card{border:1px solid var(--dsw-alias-border-l2);border-radius:9px;padding:10px;margin:0 0 10px}.wt-card-head{display:flex;align-items:flex-start;gap:8px}.wt-card-head strong{flex:1;min-width:0;overflow-wrap:anywhere}.wt-actions{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.wt-create{display:grid;grid-template-columns:1fr 1fr;gap:8px}.wt-create label{display:flex;flex-direction:column;gap:4px;min-width:0}.wt-create .wt-wide{grid-column:1/-1}.wt-check{display:flex;align-items:center;gap:7px}.wt-create label.wt-check{flex-direction:row}.wt-select,.wt-input{min-width:0;max-width:100%;background:var(--dsw-specific-sidebar-fill,transparent)}',
|
|
15
|
+
'.wt-file{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;margin:7px 0;overflow:hidden}.wt-file-head{display:flex;align-items:center;gap:6px;padding:7px}.wt-file-name{flex:1;min-width:0;overflow-wrap:anywhere}.wt-patch{white-space:pre;overflow:auto;font:12px/1.5 ui-monospace,Consolas,monospace;margin:0;padding:8px;border-top:1px solid var(--dsw-alias-border-l2)}.wt-add{color:#75c788}.wt-del{color:#df8a80}.wt-trigger{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;padding:6px 10px;color:var(--dsw-alias-label-secondary);-webkit-app-region:no-drag}.wt-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
16
|
+
'.wt-hero{position:relative;-webkit-app-region:no-drag}.wt-hero .wt-gear{width:32px;height:32px;padding:0;justify-content:center;border-color:transparent;border-radius:10px;cursor:pointer}.wt-gear[aria-expanded=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.wt-icon{width:16px;height:16px;flex:none;display:block}',
|
|
17
|
+
'.wt-popover{box-sizing:border-box;position:absolute;right:0;bottom:calc(100% + 8px);z-index:50;width:min(286px,calc(100vw - 24px));max-height:calc(100vh - 80px);overflow:auto;padding:8px;border:1px solid var(--dsw-alias-border-l2,#383838);border-radius:12px;background:var(--dsw-specific-sidebar-fill,#242424);box-shadow:0 12px 32px #0004;color:var(--dsw-alias-label-primary,#eee);font:13px/1.5 var(--dsw-font-family,system-ui),sans-serif;text-align:left;-webkit-app-region:no-drag}.wt-popover *{box-sizing:border-box}.wt-popover button,.wt-popover select{font:inherit;color:inherit}.wt-popover button:focus-visible,.wt-popover select:focus-visible,.wt-gear:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#6b96ed);outline-offset:2px}',
|
|
18
|
+
'.wt-settings-group{padding:0 6px}.wt-setting-row{min-height:38px;display:flex;align-items:center;justify-content:space-between;gap:8px}.wt-setting-label{display:flex;align-items:center;gap:8px;flex-shrink:0;font-weight:500}.wt-setting-label .wt-icon{color:var(--dsw-alias-label-secondary,#aaa)}.wt-setting-row[data-disabled=true]{opacity:.5}',
|
|
19
|
+
'.wt-switch{position:relative;display:inline-flex;width:34px;height:20px;flex:none}.wt-switch input{position:absolute;inset:0;margin:0;opacity:0;width:100%;height:100%;cursor:pointer;z-index:1}.wt-switch-track{width:100%;height:100%;border-radius:20px;background:color-mix(in srgb,currentColor 22%,transparent);transition:background .16s}.wt-switch-track:after{content:"";position:absolute;top:3px;left:3px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0003;transition:transform .16s}.wt-switch input:checked+.wt-switch-track{background:var(--dsw-alias-state-business-primary,#6b96ed)}.wt-switch input:checked+.wt-switch-track:after{transform:translateX(14px)}.wt-switch input:focus-visible+.wt-switch-track{outline:2px solid var(--dsw-alias-state-business-primary,#6b96ed);outline-offset:3px}.wt-switch input:disabled{cursor:default}.wt-switch input:disabled+.wt-switch-track{opacity:.45}',
|
|
20
|
+
'.wt-select-wrap{position:relative;min-width:0;width:144px;max-width:58%}.wt-select-wrap select{appearance:none;-webkit-appearance:none;width:100%;height:30px;min-width:0;padding:0 22px 0 6px;border:1px solid transparent;border-radius:7px;background:transparent;cursor:pointer;text-align:right;text-overflow:ellipsis}.wt-select-wrap select:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#ffffff0a);border-color:var(--dsw-alias-border-l2,#383838)}.wt-select-wrap select:disabled{cursor:default}.wt-select-wrap select option{background:var(--dsw-specific-sidebar-fill,#242424);color:var(--dsw-alias-label-primary,#eee)}.wt-select-wrap>.wt-icon{position:absolute;right:4px;top:7px;pointer-events:none;width:14px;color:var(--dsw-alias-label-secondary,#aaa)}',
|
|
21
|
+
'.wt-popover .wt-error{display:block;font-size:12px;margin:6px;line-height:1.4}.wt-popover-footer{border-top:1px solid var(--dsw-alias-border-l2,#383838);margin-top:4px;padding-top:4px}.wt-popover .wt-details{display:flex;align-items:center;justify-content:space-between;width:100%;padding:7px 6px;border:0;border-radius:7px;background:transparent;text-align:left;cursor:pointer;color:var(--dsw-alias-label-secondary,#aaa);font-size:12px}.wt-popover .wt-details:hover{background:var(--dsw-alias-interactive-bg-hover,#ffffff0a);color:var(--dsw-alias-label-primary,#eee)}@media(prefers-reduced-motion:reduce){.wt-switch-track,.wt-switch-track:after{transition:none}}',
|
|
22
|
+
'[data-row-key^="session:"][data-wt-branch="true"]::after{content:"⑂";order:2;flex:none;width:16px;color:var(--dsw-alias-label-secondary);font-size:17px;line-height:20px;text-align:center}[data-row-key^="session:"][data-wt-branch="true"]>span:first-child{order:0}[data-row-key^="session:"][data-wt-branch="true"]>span:nth-child(2){order:1}[data-row-key^="session:"][data-wt-branch="true"]>span:nth-child(n+3){order:3}',
|
|
23
|
+
'.wt-action-editor{margin-top:8px}.wt-action-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}.wt-action-head input{flex:1}.wt-panel .wt-command{display:block;width:100%;min-height:76px;resize:vertical;font:12px/1.6 ui-monospace,Consolas,monospace}',
|
|
24
|
+
'@media(max-width:420px){.wt-create{grid-template-columns:1fr}.wt-create .wt-wide{grid-column:auto}}',
|
|
25
|
+
'.wt-command-anchor{position:relative;-webkit-app-region:no-drag}.wt-command-trigger{display:flex;align-items:center;gap:3px;height:30px;padding:4px 7px!important;color:inherit;font:inherit;cursor:pointer}.wt-command-trigger .wt-icon{width:16px;height:16px}.wt-command-menu{position:fixed;z-index:150;width:264px;max-width:calc(100vw - 16px);overflow:auto;padding:8px;border:1px solid var(--dsw-alias-border-l2,#444);border-radius:12px;background:var(--dsw-specific-sidebar-fill,#242424);box-shadow:0 12px 36px #0005;color:var(--dsw-alias-label-primary,#eee);font:13px/1.5 var(--dsw-font-family,system-ui),sans-serif;-webkit-app-region:no-drag}.wt-command-caption{padding:4px 8px 8px;color:var(--dsw-alias-label-secondary,#aaa);overflow-wrap:anywhere}.wt-menu-item{display:flex;align-items:center;gap:8px;width:100%;padding:8px;border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;overflow-wrap:anywhere}.wt-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover,#ffffff12)}.wt-menu-item:disabled{opacity:.5;cursor:default}.wt-command-menu .wt-icon{width:15px;height:15px;flex:none}.wt-command-menu p{margin:6px 8px}.wt-command-menu .wt-error{padding:6px 8px;font-size:12px;max-height:120px;overflow:auto}.wt-command-footer{border-top:1px solid var(--dsw-alias-border-l2,#444);margin-top:6px;padding-top:5px}.wt-running-dot{width:6px;height:6px;border-radius:50%;background:#7ea7fa}.wt-active-command{display:flex;align-items:center;border-top:1px solid var(--dsw-alias-border-l2,#444);margin-top:5px}.wt-menu-stop{border:0;background:transparent;color:var(--dsw-alias-label-secondary,#aaa);font:inherit;white-space:nowrap;cursor:pointer;padding:6px}.wt-command-anchor button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#7ea7fa);outline-offset:1px}',
|
|
26
|
+
].join('\n')
|
|
27
|
+
|
|
28
|
+
async function call(action, body, signal) {
|
|
29
|
+
const response = await fetch('/worktree-control/api/' + action, {
|
|
30
|
+
method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
|
|
31
|
+
body: JSON.stringify(body), signal,
|
|
32
|
+
})
|
|
33
|
+
const result = await response.json()
|
|
34
|
+
if (!response.ok || !result.ok) throw Object.assign(new Error(result.error || '工作树操作失败。'), { validation: result.validation })
|
|
35
|
+
return result.value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function useSnapshot(store) { return React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) }
|
|
39
|
+
const terminalManagers = new WeakMap()
|
|
40
|
+
const environmentPages = new Set()
|
|
41
|
+
const liveJob = job => ['preparing', 'waiting', 'running'].includes(job.status)
|
|
42
|
+
function environmentsChanged(sessionId) { window.dispatchEvent(new CustomEvent('wt-environments-changed', { detail: sessionId })) }
|
|
43
|
+
function openEnvironments(ctx, sessionId) {
|
|
44
|
+
environmentPages.add(sessionId)
|
|
45
|
+
ctx.sidebarRight.openTabIn(sessionId, KIND)
|
|
46
|
+
window.dispatchEvent(new CustomEvent('wt-open-environments', { detail: sessionId }))
|
|
47
|
+
}
|
|
48
|
+
function terminalReady(view, signal) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let unsubscribe = () => {}, timer
|
|
51
|
+
const finish = error => { unsubscribe(); clearTimeout(timer); signal.removeEventListener('abort', abort); error ? reject(error) : resolve() }
|
|
52
|
+
const abort = () => finish(new Error('终端启动已取消。'))
|
|
53
|
+
const check = () => {
|
|
54
|
+
const state = view.state.getSnapshot()
|
|
55
|
+
if (state.phase === 'failed' || state.phase === 'closed' || state.info?.state === 'exited') finish(new Error(state.error || '终端无法连接。'))
|
|
56
|
+
else if (state.phase === 'connected' && state.writable) finish()
|
|
57
|
+
}
|
|
58
|
+
unsubscribe = view.state.subscribe(check)
|
|
59
|
+
timer = setTimeout(() => finish(new Error('终端连接超时。')), 20000)
|
|
60
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
61
|
+
if (signal.aborted) abort(); else check()
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
function installEnvironmentTerminals(ctx) {
|
|
65
|
+
const owner = crypto.randomUUID(), lifetime = new AbortController(), opening = new Map(), attempts = new Set()
|
|
66
|
+
let timer, polling = false, launchQueue = Promise.resolve()
|
|
67
|
+
const manager = { jobs: new Map(), errors: new Map(), listeners: new Set(),
|
|
68
|
+
notify() { for (const listener of this.listeners) listener() },
|
|
69
|
+
async show(job) {
|
|
70
|
+
if (opening.has(job.id)) return opening.get(job.id)
|
|
71
|
+
const pending = launchQueue.then(async () => {
|
|
72
|
+
if (job.status === 'preparing') { void poll(); return }
|
|
73
|
+
const terminals = ctx.get('webTerminals')
|
|
74
|
+
if (!terminals) throw new Error('请先启用 DSH 内置终端插件。')
|
|
75
|
+
let launch
|
|
76
|
+
if (job.status === 'waiting' && !job.owner) {
|
|
77
|
+
launch = await call('terminal-claim', { sessionId: job.sessionId, id: job.id, owner }, lifetime.signal)
|
|
78
|
+
if (!launch) return
|
|
79
|
+
job = launch
|
|
80
|
+
}
|
|
81
|
+
if (!launch && !job.terminalId) return
|
|
82
|
+
let tab = ctx.sidebarRight.tabsIn(job.sessionId).find(item => item.id === job.tabId && item.kind === 'terminal')
|
|
83
|
+
if (!tab && job.terminalId) tab = ctx.sidebarRight.tabsIn(job.sessionId).find(item => item.kind === 'terminal' &&
|
|
84
|
+
ctx.sidebarRight.tabDomain.occurrence(job.sessionId, { id: item.id }).navigation.getSnapshot().params?.terminalId === job.terminalId)
|
|
85
|
+
if (!tab) {
|
|
86
|
+
let params
|
|
87
|
+
if (job.terminalId) {
|
|
88
|
+
const available = await terminals.recover(job.sessionId, true)
|
|
89
|
+
if (available.some(item => item.id === job.terminalId && item.state === 'running')) params = { terminalId: job.terminalId }
|
|
90
|
+
}
|
|
91
|
+
if (!params) {
|
|
92
|
+
if (!launch) throw new Error('执行终端已关闭,无法重新打开这次日志。')
|
|
93
|
+
const choices = await terminals.launchShells(job.sessionId, lifetime.signal)
|
|
94
|
+
const shell = choices.shells.find(item => launch.platform === 'win32'
|
|
95
|
+
? /[\\/](?:powershell|pwsh)\.exe$/i.test(item.path) : /[\\/](?:bash|sh|zsh)$/.test(item.path))
|
|
96
|
+
if (!shell) throw new Error('没有可用的 PowerShell 或 sh 终端。')
|
|
97
|
+
params = { shellPath: shell.path }
|
|
98
|
+
}
|
|
99
|
+
if (ctx.sidebarRight.mounted.getSnapshot() !== job.sessionId) throw new Error('对话已切换,尚未启动的命令已取消。')
|
|
100
|
+
const before = new Set(ctx.sidebarRight.tabsIn(job.sessionId).map(item => item.id))
|
|
101
|
+
ctx.sidebarRight.openTabIn(job.sessionId, 'terminal', { params })
|
|
102
|
+
tab = ctx.sidebarRight.tabsIn(job.sessionId).find(item => item.kind === 'terminal' && !before.has(item.id))
|
|
103
|
+
} else {
|
|
104
|
+
if (ctx.sidebarRight.mounted.getSnapshot() !== job.sessionId) return
|
|
105
|
+
ctx.sidebarRight.focus(tab.id)
|
|
106
|
+
if (!ctx.sidebarRight.isExpanded()) ctx.sidebarRight.toggleExpanded()
|
|
107
|
+
}
|
|
108
|
+
if (!tab) throw new Error('无法打开此对话的内置工作台。')
|
|
109
|
+
const navigation = ctx.sidebarRight.tabDomain.occurrence(job.sessionId, { id: tab.id }).navigation.getSnapshot()
|
|
110
|
+
const view = terminals.view(job.sessionId, tab.id, navigation.address, navigation.params?.terminalId, navigation.params?.shellPath)
|
|
111
|
+
await terminalReady(view, lifetime.signal)
|
|
112
|
+
if (launch) {
|
|
113
|
+
await call('terminal-bind', { sessionId: job.sessionId, id: job.id, owner, terminalId: view.id, tabId: tab.id }, lifetime.signal)
|
|
114
|
+
await view.rename(job.title)
|
|
115
|
+
const quote = value => "'" + value.replaceAll("'", launch.platform === 'win32' ? "''" : "'" + String.fromCharCode(92) + "''") + "'"
|
|
116
|
+
const command = launch.argv.map(quote).join(' ')
|
|
117
|
+
const input = launch.platform === 'win32'
|
|
118
|
+
? 'Set-Location -LiteralPath ' + quote(job.cwd) + '; if ($?) { & ' + command + ' }' + String.fromCharCode(13)
|
|
119
|
+
: 'cd -- ' + quote(job.cwd) + ' && ' + command + String.fromCharCode(13)
|
|
120
|
+
view.write(input)
|
|
121
|
+
}
|
|
122
|
+
}).catch(async error => {
|
|
123
|
+
manager.errors.set(job.sessionId, error.message); manager.notify()
|
|
124
|
+
if ((job.status === 'waiting' || job.status === 'preparing') && (!job.owner || job.owner === owner)) await call('terminal-stop', { sessionId: job.sessionId, id: job.id, reason: error.message }).catch(() => {})
|
|
125
|
+
throw error
|
|
126
|
+
}).finally(() => opening.delete(job.id))
|
|
127
|
+
launchQueue = pending.catch(() => {})
|
|
128
|
+
opening.set(job.id, pending)
|
|
129
|
+
return pending
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
async function poll() {
|
|
133
|
+
if (polling || lifetime.signal.aborted) return
|
|
134
|
+
const sessionId = ctx.sidebarRight.mounted.getSnapshot()
|
|
135
|
+
if (!sessionId) return
|
|
136
|
+
polling = true
|
|
137
|
+
try {
|
|
138
|
+
const jobs = await call('terminal-jobs', { sessionId }, lifetime.signal)
|
|
139
|
+
manager.jobs.set(sessionId, jobs); manager.notify()
|
|
140
|
+
for (const job of jobs) if (job.status === 'waiting' && !job.owner && !attempts.has(job.id)) {
|
|
141
|
+
attempts.add(job.id)
|
|
142
|
+
void manager.show(job).catch(() => {})
|
|
143
|
+
}
|
|
144
|
+
} catch { /* A disconnected browser must never replay a launch. */ }
|
|
145
|
+
finally { polling = false }
|
|
146
|
+
}
|
|
147
|
+
const tick = async () => { await poll(); if (!lifetime.signal.aborted) timer = setTimeout(tick, 750) }
|
|
148
|
+
terminalManagers.set(ctx, manager)
|
|
149
|
+
const unsubscribe = ctx.sidebarRight.mounted.subscribe(() => { void poll() })
|
|
150
|
+
void tick()
|
|
151
|
+
return () => { lifetime.abort(); clearTimeout(timer); unsubscribe(); terminalManagers.delete(ctx) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function CommandMenuPlacement(props) {
|
|
155
|
+
const origin = React.useRef(null), [target, setTarget] = React.useState(null)
|
|
156
|
+
React.useLayoutEffect(() => {
|
|
157
|
+
const header = origin.current?.closest('header')
|
|
158
|
+
if (!header) return
|
|
159
|
+
const locate = () => {
|
|
160
|
+
const candidates = [...header.querySelectorAll('[data-git-diff-actions]')]
|
|
161
|
+
.filter(node => node.dataset.gitDiffActions === props.sessionId)
|
|
162
|
+
// The tab-row toolbar wins; its header fallback is hidden while tabs exist.
|
|
163
|
+
const next = candidates.find(node => !node.closest('.gd-toolbar-fallback')) || candidates[0] || null
|
|
164
|
+
setTarget(previous => previous === next ? previous : next)
|
|
165
|
+
}
|
|
166
|
+
const observer = new MutationObserver(locate)
|
|
167
|
+
observer.observe(header, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-git-diff-actions'] })
|
|
168
|
+
locate()
|
|
169
|
+
return () => observer.disconnect()
|
|
170
|
+
}, [props.sessionId])
|
|
171
|
+
const menu = h(CommandMenu, props)
|
|
172
|
+
return h(React.Fragment, null, h('span', { ref: origin, hidden: true }), target?.isConnected ? createPortal(menu, target) : menu)
|
|
173
|
+
}
|
|
174
|
+
function CommandMenu({ ctx, sessionId: suppliedSessionId, useSession }) {
|
|
175
|
+
const mounted = useSnapshot(ctx.sidebarRight.mounted), sessionId = suppliedSessionId || mounted
|
|
176
|
+
const directory = useSessionDirectory(ctx, sessionId)
|
|
177
|
+
const openState = useSession(value => value.openState)
|
|
178
|
+
const [open, setOpen] = React.useState(false), [switching, setSwitching] = React.useState(false)
|
|
179
|
+
const [available, setAvailable] = React.useState(false), [catalog, setCatalog] = React.useState(null)
|
|
180
|
+
const [capabilityError, setCapabilityError] = React.useState('')
|
|
181
|
+
const [revision, retryCapability] = React.useReducer(value => value + 1, 0)
|
|
182
|
+
const [error, setError] = React.useState(''), [busy, setBusy] = React.useState(false)
|
|
183
|
+
const [, redraw] = React.useReducer(value => value + 1, 0), root = React.useRef(null)
|
|
184
|
+
const manager = terminalManagers.get(ctx), jobs = manager?.jobs.get(sessionId) || []
|
|
185
|
+
React.useEffect(() => {
|
|
186
|
+
if (!manager) return
|
|
187
|
+
manager.listeners.add(redraw); return () => manager.listeners.delete(redraw)
|
|
188
|
+
}, [manager])
|
|
189
|
+
React.useEffect(() => {
|
|
190
|
+
setOpen(false); setCatalog(null); setError(''); setAvailable(false); setCapabilityError('')
|
|
191
|
+
// The header can mount before historical session hydration creates its Host Agent.
|
|
192
|
+
// Recheck when the native session is open, including after reconnect.
|
|
193
|
+
if (!sessionId || openState !== 'open') return
|
|
194
|
+
const controller = new AbortController()
|
|
195
|
+
call('capability', { sessionId }, controller.signal).then(value => {
|
|
196
|
+
if (!controller.signal.aborted) setAvailable(value.isGitRepository)
|
|
197
|
+
}).catch(cause => { if (!controller.signal.aborted) setCapabilityError(cause.message) })
|
|
198
|
+
return () => controller.abort()
|
|
199
|
+
}, [sessionId, directory, openState, revision])
|
|
200
|
+
React.useEffect(() => {
|
|
201
|
+
if (!open) return
|
|
202
|
+
const controller = new AbortController()
|
|
203
|
+
const load = () => available && call('environment-menu', { sessionId }, controller.signal).then(setCatalog).catch(cause => { if (!controller.signal.aborted) setError(cause.message) })
|
|
204
|
+
void load()
|
|
205
|
+
const change = event => { if (event.detail === sessionId) void load() }
|
|
206
|
+
const close = event => { if (!root.current?.contains(event.target)) setOpen(false) }
|
|
207
|
+
const key = event => { if (event.key === 'Escape') setOpen(false) }
|
|
208
|
+
window.addEventListener('wt-environments-changed', change); document.addEventListener('pointerdown', close); document.addEventListener('keydown', key)
|
|
209
|
+
return () => { controller.abort(); window.removeEventListener('wt-environments-changed', change); document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', key) }
|
|
210
|
+
}, [open, sessionId, available])
|
|
211
|
+
React.useLayoutEffect(() => {
|
|
212
|
+
if (!open) return
|
|
213
|
+
const position = () => {
|
|
214
|
+
const box = root.current?.getBoundingClientRect(), menu = root.current?.querySelector('.wt-command-menu')
|
|
215
|
+
if (box && menu) { menu.style.top = box.bottom + 8 + 'px'; menu.style.left = Math.max(8, Math.min(box.right - 264, window.innerWidth - 272)) + 'px'; menu.style.maxHeight = Math.max(100, window.innerHeight - box.bottom - 24) + 'px' }
|
|
216
|
+
}
|
|
217
|
+
position(); window.addEventListener('resize', position); window.addEventListener('scroll', position, true)
|
|
218
|
+
return () => { window.removeEventListener('resize', position); window.removeEventListener('scroll', position, true) }
|
|
219
|
+
}, [open, switching])
|
|
220
|
+
async function perform(operation) {
|
|
221
|
+
setBusy(true); setError('')
|
|
222
|
+
try { await operation() } catch (cause) { setError(cause.message) } finally { setBusy(false) }
|
|
223
|
+
}
|
|
224
|
+
async function run(id) {
|
|
225
|
+
manager.errors.delete(sessionId)
|
|
226
|
+
const value = await call('environment-action', { sessionId, id, environmentId: catalog.selectedId })
|
|
227
|
+
await manager.show(value.terminalJob)
|
|
228
|
+
setOpen(false)
|
|
229
|
+
}
|
|
230
|
+
async function select(id) {
|
|
231
|
+
await call('environment-select', { sessionId, id, kind: catalog.draft ? 'draft' : 'session', draft: catalog.draft || undefined })
|
|
232
|
+
setCatalog(await call('environment-menu', { sessionId })); setSwitching(false); environmentsChanged(sessionId)
|
|
233
|
+
}
|
|
234
|
+
if (!available && !capabilityError) return null
|
|
235
|
+
const profile = catalog?.profiles.find(item => item.id === catalog.selectedId)
|
|
236
|
+
const latest = jobs.at(-1)
|
|
237
|
+
const message = capabilityError || error || manager?.errors.get(sessionId) || (latest?.status === 'failed' || latest?.status === 'stopped' ? latest.error : '')
|
|
238
|
+
return h('div', { className: 'wt-command-anchor', ref: root },
|
|
239
|
+
h('button', { className: 'wt-btn wt-command-trigger', type: 'button', title: '环境命令', 'aria-label': '环境命令', 'aria-expanded': open, onClick: () => { setOpen(!open); setSwitching(false); setError('') } },
|
|
240
|
+
h(SettingsIcon, { name: 'play' }), h(SettingsIcon, { name: 'chevron' }), jobs.some(liveJob) ? h('span', { className: 'wt-running-dot', title: '命令执行中' }) : null),
|
|
241
|
+
open ? h('div', { className: 'wt-command-menu', role: 'dialog', 'aria-label': '环境命令菜单' },
|
|
242
|
+
h('div', { className: 'wt-command-caption' }, capabilityError ? '环境命令暂不可用' : switching ? '切换环境' : profile?.name || '未选择环境'),
|
|
243
|
+
capabilityError ? null : !catalog ? h('p', { className: 'wt-muted' }, '正在读取…') : switching
|
|
244
|
+
? [h('button', { key: 'none', type: 'button', className: 'wt-menu-item', disabled: busy, onClick: () => perform(() => select('')) }, !catalog.selectedId ? '✓ 无环境' : '无环境'),
|
|
245
|
+
...catalog.profiles.map(item => h('button', { key: item.id, type: 'button', className: 'wt-menu-item', disabled: busy, onClick: () => perform(() => select(item.id)) }, (item.id === catalog.selectedId ? '✓ ' : '') + item.name))]
|
|
246
|
+
: profile?.actions.length ? profile.actions.map((item, id) => h('button', { key: id, type: 'button', className: 'wt-menu-item', title: item.command, disabled: busy, onClick: () => perform(() => run(id)) }, h(SettingsIcon, { name: 'play' }), item.name))
|
|
247
|
+
: h('p', { className: 'wt-muted' }, profile ? '此环境尚未配置命令。' : '选择一个环境后即可运行命令。'),
|
|
248
|
+
jobs.filter(liveJob).map(job => h('div', { key: job.id, className: 'wt-active-command' },
|
|
249
|
+
h('button', { type: 'button', className: 'wt-menu-item', onClick: () => perform(() => manager.show(job)) }, job.title, ' · ', job.status === 'running' ? '运行中' : '正在启动'),
|
|
250
|
+
h('button', { type: 'button', className: 'wt-menu-stop', 'aria-label': '停止 ' + job.title, onClick: () => perform(() => call('terminal-stop', { sessionId, id: job.id })) }, '停止'))),
|
|
251
|
+
message ? h('div', { className: 'wt-error', role: 'alert' }, message) : null,
|
|
252
|
+
h('div', { className: 'wt-command-footer' },
|
|
253
|
+
capabilityError ? h('button', { type: 'button', className: 'wt-menu-item', onClick: retryCapability }, '重新检查') : null,
|
|
254
|
+
h('button', { type: 'button', className: 'wt-menu-item', disabled: !catalog || busy, onClick: () => setSwitching(!switching) }, switching ? '返回命令列表' : '切换环境', ' ›'),
|
|
255
|
+
h('button', { type: 'button', className: 'wt-menu-item', onClick: () => { setOpen(false); openEnvironments(ctx, sessionId) } }, '管理环境…')),
|
|
256
|
+
) : null)
|
|
257
|
+
}
|
|
258
|
+
function useSessionDirectory(ctx, sessionId) {
|
|
259
|
+
return React.useSyncExternalStore(ctx.sessions.list.subscribe, () => {
|
|
260
|
+
const row = ctx.sessions.list.getSnapshot().byId[sessionId]
|
|
261
|
+
return row?.projectionValues?.workingDirectory || row?.cwd || ''
|
|
262
|
+
}, () => '')
|
|
263
|
+
}
|
|
264
|
+
const locationCache = new Map()
|
|
265
|
+
const locationQueue = []
|
|
266
|
+
let activeLocations = 0
|
|
267
|
+
function drainLocations() {
|
|
268
|
+
while (activeLocations < 4 && locationQueue.length) {
|
|
269
|
+
const job = locationQueue.shift()
|
|
270
|
+
activeLocations++
|
|
271
|
+
call('location-kind', { path: job.path }).then(value => job.resolve(Boolean(value.worktree)), () => job.resolve(false))
|
|
272
|
+
.finally(() => { activeLocations--; drainLocations() })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function worktreeLocation(path) {
|
|
276
|
+
if (!path) return Promise.resolve(false)
|
|
277
|
+
if (!locationCache.has(path)) {
|
|
278
|
+
locationCache.set(path, new Promise(resolve => { locationQueue.push({ path, resolve }); drainLocations() }))
|
|
279
|
+
}
|
|
280
|
+
return locationCache.get(path)
|
|
281
|
+
}
|
|
282
|
+
function RowBadge({ ctx, sessionId }) {
|
|
283
|
+
const directory = React.useSyncExternalStore(ctx.sessions.list.subscribe, () => {
|
|
284
|
+
const row = ctx.sessions.list.getSnapshot().byId[sessionId]
|
|
285
|
+
return row?.projectionValues?.workingDirectory || row?.cwd || ''
|
|
286
|
+
}, () => '')
|
|
287
|
+
const ref = React.useRef(null)
|
|
288
|
+
const [worktree, setWorktree] = React.useState(false)
|
|
289
|
+
React.useEffect(() => {
|
|
290
|
+
let active = true
|
|
291
|
+
worktreeLocation(directory).then(value => { if (active) setWorktree(value) })
|
|
292
|
+
return () => { active = false }
|
|
293
|
+
}, [directory])
|
|
294
|
+
React.useEffect(() => {
|
|
295
|
+
const row = ref.current?.closest('[data-row-key^="session:"]')
|
|
296
|
+
if (!row) return
|
|
297
|
+
if (worktree) row.dataset.wtBranch = 'true'
|
|
298
|
+
else delete row.dataset.wtBranch
|
|
299
|
+
return () => { delete row.dataset.wtBranch }
|
|
300
|
+
}, [worktree])
|
|
301
|
+
return h('span', { ref, hidden: true, 'data-wt-marker': '' })
|
|
302
|
+
}
|
|
303
|
+
function lineCounts(patch) {
|
|
304
|
+
let additions = 0, deletions = 0
|
|
305
|
+
for (const line of patch.split('\n')) {
|
|
306
|
+
if (line.startsWith('+') && !line.startsWith('+++')) additions++
|
|
307
|
+
if (line.startsWith('-') && !line.startsWith('---')) deletions++
|
|
308
|
+
}
|
|
309
|
+
return { additions, deletions }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function Panel({ ctx }) {
|
|
313
|
+
const sessionId = useSnapshot(ctx.sidebarRight.mounted)
|
|
314
|
+
const directory = useSessionDirectory(ctx, sessionId)
|
|
315
|
+
const [tab, setTab] = React.useState(() => environmentPages.has(sessionId) ? 'environments' : 'trees'), [catalog, setCatalog] = React.useState(null)
|
|
316
|
+
const [capability, setCapability] = React.useState(null)
|
|
317
|
+
const [diff, setDiff] = React.useState(null), [baseRef, setBaseRef] = React.useState('HEAD')
|
|
318
|
+
const [branch, setBranch] = React.useState(''), [includeChanges, setIncludeChanges] = React.useState(true)
|
|
319
|
+
const [runSetupOnCreate, setRunSetupOnCreate] = React.useState(false), [retention, setRetention] = React.useState('15')
|
|
320
|
+
const [worktreeRoot, setWorktreeRoot] = React.useState('')
|
|
321
|
+
const [editor, setEditorState] = React.useState({ id: '', name: '', setup: '', actions: [] })
|
|
322
|
+
const [actionIssue, setActionIssue] = React.useState(null), editorRoot = React.useRef(null)
|
|
323
|
+
const actionErrorId = React.useId()
|
|
324
|
+
const setEditor = value => { setEditorState(value); setActionIssue(null) }
|
|
325
|
+
React.useLayoutEffect(() => {
|
|
326
|
+
if (!actionIssue) return
|
|
327
|
+
const field = editorRoot.current?.querySelector('[data-environment-action="' + actionIssue.actionIndex + '"] [data-environment-field="' + actionIssue.field + '"]')
|
|
328
|
+
if (!field) { setError(actionIssue.message); setActionIssue(null); return }
|
|
329
|
+
field?.focus({ preventScroll: true })
|
|
330
|
+
field?.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
331
|
+
}, [actionIssue])
|
|
332
|
+
const [commitMessage, setCommitMessage] = React.useState(''), [expanded, setExpanded] = React.useState('')
|
|
333
|
+
const [busy, setBusy] = React.useState(''), [error, setError] = React.useState(''), [notice, setNotice] = React.useState('')
|
|
334
|
+
const savingEnvironment = busy === 'environment-save'
|
|
335
|
+
const [confirmation, setConfirmation] = React.useState(null)
|
|
336
|
+
const confirmAction = (message, accept) => setConfirmation({ message, accept })
|
|
337
|
+
const [revision, refresh] = React.useReducer(value => value + 1, 0)
|
|
338
|
+
React.useEffect(() => {
|
|
339
|
+
const open = event => { if (event.detail === sessionId) { setTab('environments'); refresh() } }
|
|
340
|
+
const changed = event => { if (event.detail === sessionId) refresh() }
|
|
341
|
+
window.addEventListener('wt-open-environments', open); window.addEventListener('wt-environments-changed', changed)
|
|
342
|
+
return () => { window.removeEventListener('wt-open-environments', open); window.removeEventListener('wt-environments-changed', changed) }
|
|
343
|
+
}, [sessionId])
|
|
344
|
+
|
|
345
|
+
React.useEffect(() => {
|
|
346
|
+
setCatalog(null); setDiff(null); setCapability(null); setError('')
|
|
347
|
+
if (!sessionId) return
|
|
348
|
+
const controller = new AbortController()
|
|
349
|
+
call('capability', { sessionId }, controller.signal).then(async value => {
|
|
350
|
+
setCapability({ sessionId, directory, ...value })
|
|
351
|
+
if (!value.isGitRepository) return
|
|
352
|
+
const catalogValue = await call('catalog', { sessionId }, controller.signal)
|
|
353
|
+
return catalogValue
|
|
354
|
+
}).then(value => {
|
|
355
|
+
if (!value) return
|
|
356
|
+
setCatalog(value); setRetention(String(value.retentionLimit)); setWorktreeRoot(value.worktreeRoot);
|
|
357
|
+
if (value.branches.includes(baseRef) || baseRef === 'HEAD') return
|
|
358
|
+
setBaseRef('HEAD')
|
|
359
|
+
}).catch(cause => { if (!controller.signal.aborted) setError(cause.message) })
|
|
360
|
+
return () => controller.abort()
|
|
361
|
+
}, [sessionId, directory, revision])
|
|
362
|
+
React.useEffect(() => { setError(''); setNotice(''); setCommitMessage(''); setBranch(''); setConfirmation(null); setActionIssue(null) }, [sessionId])
|
|
363
|
+
|
|
364
|
+
React.useEffect(() => {
|
|
365
|
+
setDiff(null); setExpanded('')
|
|
366
|
+
if (!sessionId || tab !== 'diff' || capability?.sessionId !== sessionId || capability.directory !== directory || !capability.isGitRepository) return
|
|
367
|
+
const controller = new AbortController()
|
|
368
|
+
call('inspect', { sessionId }, controller.signal).then(setDiff).catch(cause => { if (!controller.signal.aborted) setError(cause.message) })
|
|
369
|
+
return () => controller.abort()
|
|
370
|
+
}, [sessionId, directory, tab, revision, capability])
|
|
371
|
+
|
|
372
|
+
async function act(action, extra = {}, success = '') {
|
|
373
|
+
if (!sessionId || busy) return
|
|
374
|
+
setBusy(action); setError(''); setNotice('')
|
|
375
|
+
if (action === 'environment-save') setActionIssue(null)
|
|
376
|
+
try {
|
|
377
|
+
const value = await call(action, { sessionId, ...extra })
|
|
378
|
+
if (value?.terminalJob) {
|
|
379
|
+
setNotice('已交给内置工作台执行,可在终端查看输出。')
|
|
380
|
+
await terminalManagers.get(ctx).show(value.terminalJob)
|
|
381
|
+
return value
|
|
382
|
+
}
|
|
383
|
+
if (action.startsWith('environment-')) environmentsChanged(sessionId)
|
|
384
|
+
if (success) setNotice(success)
|
|
385
|
+
if (value?.warnings?.length) setNotice([success, ...value.warnings].filter(Boolean).join('\n'))
|
|
386
|
+
if (value?.output) setNotice([success, value.output].filter(Boolean).join('\n'))
|
|
387
|
+
if (action === 'create' && value?.setupOutput) setNotice(value.setupOutput || '项目初始化完成。')
|
|
388
|
+
setExpanded(''); refresh()
|
|
389
|
+
return value
|
|
390
|
+
} catch (cause) {
|
|
391
|
+
const { actionIndex, field } = cause.validation || {}
|
|
392
|
+
if (action === 'environment-save' && Number.isInteger(actionIndex) && actionIndex >= 0 && extra.profile?.actions?.[actionIndex] && ['name', 'command'].includes(field)) {
|
|
393
|
+
setActionIssue({ actionIndex, field, message: cause.message })
|
|
394
|
+
}
|
|
395
|
+
else setError(cause.message)
|
|
396
|
+
}
|
|
397
|
+
finally { setBusy('') }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!sessionId) return h('div', { className: 'wt-panel wt-scroll' }, '请先打开一个带 Git 工作目录的对话。')
|
|
401
|
+
if (capability?.sessionId !== sessionId || capability.directory !== directory) return h('div', { className: 'wt-panel wt-scroll wt-muted', 'data-plugin': 'dsh-asuta-worktree-control' }, error || '正在检查 Git 工作目录…')
|
|
402
|
+
if (capability?.sessionId === sessionId && capability.directory === directory && !capability.isGitRepository) return h('div', { className: 'wt-panel wt-scroll wt-muted', 'data-plugin': 'dsh-asuta-worktree-control' }, '当前项目不是 Git 仓库')
|
|
403
|
+
const allTrees = catalog?.worktrees || []
|
|
404
|
+
const currentTree = allTrees.find(tree => tree.current)
|
|
405
|
+
const handoffTarget = currentTree?.path === catalog?.primaryRoot
|
|
406
|
+
? allTrees.filter(tree => tree.managed && !tree.current && tree.sessionIds?.includes(sessionId)).sort((a, b) => (b.lastUsedAt || '').localeCompare(a.lastUsedAt || ''))[0]
|
|
407
|
+
: allTrees.find(tree => tree.path === catalog?.primaryRoot)
|
|
408
|
+
|
|
409
|
+
return h('div', { className: 'wt-panel', 'data-plugin': 'dsh-asuta-worktree-control' },
|
|
410
|
+
h('div', { className: 'wt-head' }, h('strong', null, '工作树'), h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => refresh() }, '刷新')),
|
|
411
|
+
h('div', { className: 'wt-tabs', role: 'tablist' },
|
|
412
|
+
h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'trees', onClick: () => setTab('trees') }, '工作树'),
|
|
413
|
+
h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'diff', onClick: () => setTab('diff') }, '差异与 Git'),
|
|
414
|
+
h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'environments', onClick: () => setTab('environments') }, '环境'),
|
|
415
|
+
),
|
|
416
|
+
error ? h('div', { className: 'wt-error', role: 'alert', style: { padding: '8px 14px' } }, error) : null,
|
|
417
|
+
catalog?.environments?.setupWarning ? h('div', { className: 'wt-error', role: 'alert', style: { padding: '8px 14px' } }, catalog.environments.setupWarning, '。可在“差异与 Git”中重试初始化。') : null,
|
|
418
|
+
notice ? h('div', { className: 'wt-ok', role: 'status', style: { padding: '8px 14px' } }, notice) : null,
|
|
419
|
+
confirmation ? h('div', { className: 'wt-card', role: 'alertdialog', 'aria-label': '确认工作树操作', style: { margin: '10px 14px' } },
|
|
420
|
+
h('p', { style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' } }, confirmation.message),
|
|
421
|
+
h('div', { className: 'wt-actions' }, h('button', { className: 'wt-btn', type: 'button', onClick: () => { const accept = confirmation.accept; setConfirmation(null); accept() } }, '确认继续'), h('button', { className: 'wt-btn', type: 'button', onClick: () => setConfirmation(null) }, '取消')),
|
|
422
|
+
) : null,
|
|
423
|
+
tab === 'environments' ? h('div', { className: 'wt-scroll' },
|
|
424
|
+
h('div', { className: 'wt-card' },
|
|
425
|
+
h('strong', null, '此对话的环境'),
|
|
426
|
+
h('select', { className: 'wt-select', style: { width: '100%', marginTop: '8px' }, value: catalog?.environments?.selectedId || '', onChange: event => act('environment-select', { kind: catalog?.environments?.draft ? 'draft' : 'session', draft: catalog?.environments?.draft ? { create: catalog.environments.draft.create, baseRef: catalog.environments.draft.baseRef } : undefined, id: event.target.value }, '已切换此对话的环境。') },
|
|
427
|
+
h('option', { value: '' }, '无环境'), ...(catalog?.environments?.profiles || []).map(item => h('option', { key: item.id, value: item.id }, item.name))),
|
|
428
|
+
h('p', { className: 'wt-muted' }, '切换环境不会自动重跑初始化;需要时在“差异与 Git”中手动运行。'),
|
|
429
|
+
),
|
|
430
|
+
h('div', { className: 'wt-card' },
|
|
431
|
+
h('strong', null, '项目环境配置'),
|
|
432
|
+
(catalog?.environments?.profiles || []).map(item => h('div', { key: item.id, className: 'wt-actions', style: { alignItems: 'center' } },
|
|
433
|
+
h('span', { style: { flex: 1 } }, item.name, item.id === catalog.environments.defaultId ? ' · 默认' : ''),
|
|
434
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => setEditor({ id: item.imported ? '' : item.id, name: item.imported ? item.name + ' 副本' : item.name, setup: item.setup, actions: item.actions.map(action => ({ name: action.name, command: action.command })) }) }, item.imported ? '导入副本' : '编辑'),
|
|
435
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => act('environment-select', { kind: 'default', id: item.id }, '默认环境已更新。') }, '设为默认'),
|
|
436
|
+
!item.imported ? h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => confirmAction('删除环境“' + item.name + '”?关联对话会改为无环境。', () => act('environment-delete', { id: item.id }, '环境已删除。')) }, '删除') : null,
|
|
437
|
+
)),
|
|
438
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), style: { marginTop: '10px' }, onClick: () => act('environment-select', { kind: 'default', id: '' }, '默认环境已清除。') }, '默认使用无环境'),
|
|
439
|
+
),
|
|
440
|
+
h('div', { className: 'wt-card', ref: editorRoot },
|
|
441
|
+
h('strong', null, editor.id ? '编辑环境' : '新建环境'),
|
|
442
|
+
h('label', { style: { display: 'block', marginTop: '8px' } }, '名称', h('input', { className: 'wt-input', disabled: savingEnvironment, style: { display: 'block', width: '100%' }, value: editor.name, maxLength: 80, onChange: event => setEditor({ ...editor, name: event.target.value }) })),
|
|
443
|
+
h('label', { style: { display: 'block', marginTop: '8px' } }, '初始化脚本(新建工作树后运行)', h('textarea', { className: 'wt-input', disabled: savingEnvironment, style: { display: 'block', width: '100%', minHeight: '75px' }, value: editor.setup, onChange: event => setEditor({ ...editor, setup: event.target.value }) })),
|
|
444
|
+
h('strong', { style: { display: 'block', marginTop: '10px' } }, '手动操作'),
|
|
445
|
+
h('div', { className: 'wt-muted' }, '支持多行命令,失败即停止后续执行。完全空白的操作保存时自动忽略。'),
|
|
446
|
+
editor.actions.map((item, index) => h('div', { key: index, className: 'wt-action-editor', 'data-environment-action': index },
|
|
447
|
+
h('div', { className: 'wt-action-head' },
|
|
448
|
+
h('input', { className: 'wt-input', disabled: savingEnvironment, value: item.name, placeholder: '名称', 'aria-label': '操作名称 ' + (index + 1), 'data-environment-field': 'name',
|
|
449
|
+
'aria-invalid': actionIssue?.actionIndex === index && actionIssue.field === 'name', 'aria-describedby': actionIssue?.actionIndex === index ? actionErrorId : undefined,
|
|
450
|
+
onChange: event => setEditor({ ...editor, actions: editor.actions.map((action, i) => i === index ? { ...action, name: event.target.value } : action) }) }),
|
|
451
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: savingEnvironment, onClick: () => setEditor({ ...editor, actions: editor.actions.filter((_, i) => i !== index) }) }, '移除'),
|
|
452
|
+
),
|
|
453
|
+
h('textarea', { className: 'wt-input wt-command', disabled: savingEnvironment, rows: 3, spellCheck: false, value: item.command, placeholder: '每行输入一条命令', 'aria-label': '操作命令 ' + (index + 1), 'data-environment-field': 'command',
|
|
454
|
+
'aria-invalid': actionIssue?.actionIndex === index && actionIssue.field === 'command', 'aria-describedby': actionIssue?.actionIndex === index ? actionErrorId : undefined,
|
|
455
|
+
onChange: event => setEditor({ ...editor, actions: editor.actions.map((action, i) => i === index ? { ...action, command: event.target.value } : action) }) }),
|
|
456
|
+
actionIssue?.actionIndex === index ? h('p', { id: actionErrorId, className: 'wt-error', role: 'alert' }, actionIssue.message) : null,
|
|
457
|
+
)),
|
|
458
|
+
h('div', { className: 'wt-actions' },
|
|
459
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: savingEnvironment, onClick: () => setEditor({ ...editor, actions: [...editor.actions, { name: '', command: '' }] }) }, '添加操作'),
|
|
460
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy) || !editor.name.trim(), onClick: async () => { if (await act('environment-save', { profile: editor }, '环境已保存。')) setEditor({ id: '', name: '', setup: '', actions: [] }) } }, '保存环境'),
|
|
461
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: savingEnvironment, onClick: () => setEditor({ id: '', name: '', setup: '', actions: [] }) }, '清空表单'),
|
|
462
|
+
),
|
|
463
|
+
),
|
|
464
|
+
) : tab === 'trees' ? h('div', { className: 'wt-scroll' },
|
|
465
|
+
h('div', { className: 'wt-card' },
|
|
466
|
+
h('div', { className: 'wt-card-head' }, h('strong', null, '当前对话目录'), h('span', { className: 'wt-muted' }, currentTree?.branch || (currentTree?.detached ? 'detached HEAD' : '本地'))),
|
|
467
|
+
h('div', { className: 'wt-muted' }, catalog?.currentPath || '正在读取…'),
|
|
468
|
+
h('label', { style: { display: 'block', marginTop: '8px' } }, h('span', { className: 'wt-muted' }, '切换目录'),
|
|
469
|
+
h('select', { className: 'wt-select', disabled: Boolean(busy), style: { width: '100%' }, value: currentTree?.path || '', onChange: event => { if (event.target.value) act('switch', { target: event.target.value }, '已在同一对话中切换工作目录。') } },
|
|
470
|
+
allTrees.map(tree => h('option', { key: tree.path, value: tree.path }, (tree.path === catalog?.primaryRoot ? '本地 · ' : '工作树 · ') + (tree.branch || 'detached') + ' — ' + tree.path)),
|
|
471
|
+
),
|
|
472
|
+
),
|
|
473
|
+
h('div', { className: 'wt-actions' },
|
|
474
|
+
handoffTarget ? h('button', { className: 'wt-btn', disabled: Boolean(busy), type: 'button', onClick: () => { confirmAction('把当前成果复制到 ' + handoffTarget.path + ' 并在同一对话中继续?源目录保留原样;目标须没有未提交改动,带入的成果将暂存。', () => act('handoff', { target: handoffTarget.path }, '已交接并切换,对话历史保持不变。')) } }, currentTree?.path === catalog?.primaryRoot ? '交接回工作树' : '交接到本地') : null,
|
|
475
|
+
),
|
|
476
|
+
),
|
|
477
|
+
h('div', { className: 'wt-card' }, h('strong', null, '新建工作树'),
|
|
478
|
+
h('div', { className: 'wt-create', style: { marginTop: '8px' } },
|
|
479
|
+
h('label', null, h('span', { className: 'wt-muted' }, '基础分支'), h('select', { className: 'wt-select', value: baseRef, onChange: event => setBaseRef(event.target.value) },
|
|
480
|
+
h('option', { value: 'HEAD' }, '当前提交 (HEAD)'), ...(catalog?.branches || []).map(ref => h('option', { key: ref, value: ref }, ref)))),
|
|
481
|
+
h('label', null, h('span', { className: 'wt-muted' }, '新分支(留空则 detached)'), h('input', { className: 'wt-input', value: branch, onChange: event => setBranch(event.target.value), placeholder: '例如 feature/my-task' })),
|
|
482
|
+
h('label', { className: 'wt-wide wt-check' }, h('input', { type: 'checkbox', checked: includeChanges, onChange: event => setIncludeChanges(event.target.checked) }), '把当前未提交改动复制到新工作树'),
|
|
483
|
+
h('label', { className: 'wt-wide wt-check' }, h('input', { type: 'checkbox', checked: runSetupOnCreate, onChange: event => setRunSetupOnCreate(event.target.checked) }), '创建后运行所选环境或项目的 setup.script'),
|
|
484
|
+
h('button', { className: 'wt-btn wt-wide', type: 'button', disabled: Boolean(busy), onClick: () => act('create', { baseRef, branch: branch.trim(), includeChanges, runSetup: runSetupOnCreate, retentionLimit: Number(retention) }, '工作树已创建并切换;对话历史保持不变。') }, busy === 'create' ? '正在创建…' : '创建并切换到工作树'),
|
|
485
|
+
),
|
|
486
|
+
h('p', { className: 'wt-muted' }, '新目录位于下方设置的工作树根目录;.worktreeinclude 匹配的忽略文件也会复制。'),
|
|
487
|
+
),
|
|
488
|
+
h('div', { className: 'wt-card' }, h('div', { className: 'wt-card-head' }, h('strong', null, '自动保留'), h('span', { className: 'wt-muted' }, '超出上限时先快照再归档')),
|
|
489
|
+
h('label', null, h('span', { className: 'wt-muted' }, '最多保留多少棵工作树?0 表示关闭自动清理。'), h('input', { className: 'wt-input', type: 'number', min: 0, max: 200, value: retention, onChange: event => setRetention(event.target.value), onBlur: () => { const value = Number(retention); if (Number.isInteger(value) && value >= 0 && value <= 200 && value !== catalog?.retentionLimit) act('retention', { value }, '保留数量已保存。') } })),
|
|
490
|
+
h('label', { style: { display: 'block', marginTop: '8px' } }, h('span', { className: 'wt-muted' }, '工作树根目录(仅影响新建工作树)'), h('input', { className: 'wt-input', style: { width: '100%' }, value: worktreeRoot, onChange: event => setWorktreeRoot(event.target.value) })),
|
|
491
|
+
h('button', { className: 'wt-btn', disabled: Boolean(busy) || !worktreeRoot || worktreeRoot === catalog?.worktreeRoot, type: 'button', onClick: () => act('retention', { worktreeRoot }, '工作树根目录已保存。') }, '保存根目录'),
|
|
492
|
+
),
|
|
493
|
+
h('strong', null, '受管理的工作树'),
|
|
494
|
+
catalog ? catalog.worktrees.length ? catalog.worktrees.map(tree => h('div', { className: 'wt-card', key: tree.path },
|
|
495
|
+
h('div', { className: 'wt-card-head' }, h('strong', null, tree.branch || 'detached HEAD'), h('span', { className: 'wt-muted' }, tree.current ? '当前对话' : tree.pinned ? '已固定' : '')),
|
|
496
|
+
h('div', { className: 'wt-muted' }, tree.path),
|
|
497
|
+
h('div', { className: 'wt-actions' },
|
|
498
|
+
!tree.current ? h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => act('switch', { target: tree.path }, '已在同一对话中切换工作目录。') }, '切换到此处') : null,
|
|
499
|
+
tree.managed ? h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => act('pin', { path: tree.path, pinned: !tree.pinned }, tree.pinned ? '已取消固定。' : '已固定,不会被自动清理。') }, tree.pinned ? '取消固定' : '固定') : null,
|
|
500
|
+
tree.managed && !tree.current ? h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => { confirmAction('归档前会保存 Git 已跟踪改动、未忽略的未跟踪文件和 .worktreeinclude 文件;其余忽略文件不会保留。确定继续?', () => act('archive', { path: tree.path }, '工作树已快照并归档。')) } }, '快照并归档') : null,
|
|
501
|
+
),
|
|
502
|
+
)) : h('p', { className: 'wt-muted' }, '尚无由此插件创建的工作树。') : h('p', { className: 'wt-muted' }, '正在读取工作树…'),
|
|
503
|
+
h('strong', { style: { display: 'block', marginTop: '16px' } }, '可恢复快照'),
|
|
504
|
+
catalog?.archives?.length ? catalog.archives.map(item => h('div', { className: 'wt-card', key: item.id },
|
|
505
|
+
h('div', { className: 'wt-card-head' }, h('strong', null, item.branch || 'detached HEAD'), h('span', { className: 'wt-muted' }, new Date(item.archivedAt).toLocaleString())),
|
|
506
|
+
h('div', { className: 'wt-muted' }, item.path),
|
|
507
|
+
h('div', { className: 'wt-actions' },
|
|
508
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => act('restore', { id: item.id }, '快照已恢复,并切换到恢复的工作树。') }, '恢复并切换'),
|
|
509
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => { confirmAction('永久删除这份快照?此操作无法撤销。', () => act('delete-archive', { id: item.id }, '快照已永久删除。')) } }, '删除快照'),
|
|
510
|
+
),
|
|
511
|
+
)) : h('p', { className: 'wt-muted' }, '暂无可恢复快照。'),
|
|
512
|
+
) : h('div', { className: 'wt-scroll' },
|
|
513
|
+
h('div', { className: 'wt-card' }, h('div', { className: 'wt-card-head' }, h('strong', null, '当前工作树差异'), h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => refresh() }, '刷新')),
|
|
514
|
+
h('p', { className: 'wt-muted' }, diff ? diff.root + (diff.branch ? ' · ' + diff.branch : ' · detached HEAD') : '正在读取差异…'),
|
|
515
|
+
diff?.files?.length ? diff.files.map(file => {
|
|
516
|
+
const counts = lineCounts(file.patch)
|
|
517
|
+
return h('div', { className: 'wt-file', key: file.path },
|
|
518
|
+
h('div', { className: 'wt-file-head' },
|
|
519
|
+
h('button', { className: 'wt-btn wt-file-name', type: 'button', 'aria-expanded': expanded === file.path, onClick: () => setExpanded(expanded === file.path ? '' : file.path) }, file.path + (file.untracked ? ' · 未跟踪' : '')),
|
|
520
|
+
h('span', { className: 'wt-add' }, '+' + counts.additions), h('span', { className: 'wt-del' }, '-' + counts.deletions),
|
|
521
|
+
file.staged ? h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => act('unstage', { paths: [file.path] }) }, '取消暂存') : null,
|
|
522
|
+
!file.staged || file.unstaged ? h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => act('stage', { paths: [file.path] }) }, file.staged ? '暂存剩余改动' : '暂存') : null,
|
|
523
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => { confirmAction('丢弃 ' + file.path + ' 的未提交改动?', () => act('discard', { paths: [file.path] })) } }, '丢弃'),
|
|
524
|
+
),
|
|
525
|
+
expanded === file.path ? h('pre', { className: 'wt-patch' }, file.patch || '没有可显示的文本差异。') : null,
|
|
526
|
+
)
|
|
527
|
+
}) : diff ? h('p', { className: 'wt-muted' }, '当前没有改动。') : null,
|
|
528
|
+
),
|
|
529
|
+
h('div', { className: 'wt-card' }, h('strong', null, '提交、推送与 PR'),
|
|
530
|
+
h('label', { style: { display: 'block', marginTop: '8px' } }, h('span', { className: 'wt-muted' }, '提交说明(仅提交已暂存内容)'), h('input', { className: 'wt-input', style: { width: '100%' }, value: commitMessage, onChange: event => setCommitMessage(event.target.value), placeholder: '例如:修复工作树切换' })),
|
|
531
|
+
h('div', { className: 'wt-actions' },
|
|
532
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: async () => { const result = await act('commit', { message: commitMessage }, '提交已创建。'); if (result) setCommitMessage('') } }, '提交'),
|
|
533
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => act('push', {}, '已推送到远端。') }, '推送'),
|
|
534
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => { confirmAction('将调用 gh pr create --web 打开 PR 创建页面。继续?', () => act('pull-request', {}, '已打开 PR 创建流程。')) } }, '创建 PR'),
|
|
535
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy), onClick: () => { confirmAction('运行当前环境的 setup.script?脚本将修改当前工作树。', () => act('setup', {}, '项目初始化完成。')) } }, '运行当前环境 setup'),
|
|
536
|
+
),
|
|
537
|
+
h('label', { style: { display: 'block', marginTop: '8px' } }, h('span', { className: 'wt-muted' }, '新分支名称(用于 detached 工作树)'), h('input', { className: 'wt-input', value: branch, onChange: event => setBranch(event.target.value), placeholder: 'feature/my-task' })),
|
|
538
|
+
h('button', { className: 'wt-btn', type: 'button', disabled: Boolean(busy) || !branch.trim(), onClick: () => act('branch', { branch: branch.trim() }, '已在当前工作树创建分支。') }, '在此创建分支'),
|
|
539
|
+
(catalog?.environments?.profiles.find(item => item.id === catalog.environments.selectedId)?.actions || []).length ? h('div', { className: 'wt-actions' }, catalog.environments.profiles.find(item => item.id === catalog.environments.selectedId).actions.map((item, id) => h('button', { key: id, type: 'button', className: 'wt-btn', disabled: Boolean(busy), title: item.command, onClick: () => { confirmAction('运行项目操作“' + item.name + '”?\n' + item.command, () => act('environment-action', { id }, '项目操作已完成。')) } }, item.name))) : null,
|
|
540
|
+
),
|
|
541
|
+
),
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function SettingsIcon({ name }) {
|
|
546
|
+
const paths = {
|
|
547
|
+
worktree: 'M6 3v12m0-7c7 0 12 0 12 7M3 12l3 3 3-3m6 0 3 3 3-3',
|
|
548
|
+
branch: 'M6 6v12m12-12v3c0 4-12 2-12 6M4 3h4v4H4zM16 3h4v4h-4zM4 17h4v4H4z',
|
|
549
|
+
environment: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3zm-8 4.5 8 4.5 8-4.5M12 12v9',
|
|
550
|
+
chevron: 'm8 10 4 4 4-4',
|
|
551
|
+
arrow: 'M5 12h14m-5-5 5 5-5 5',
|
|
552
|
+
info: 'M12 11v6m0-10v.1M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',
|
|
553
|
+
settings: 'M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1 1-3zM16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0',
|
|
554
|
+
play: 'm8 4 12 8-12 8z',
|
|
555
|
+
}
|
|
556
|
+
return h('svg', { className: 'wt-icon', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, h('path', { d: paths[name] }))
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function HeroSettings({ ctx }) {
|
|
560
|
+
const sessionId = useSnapshot(ctx.sidebarRight.mounted)
|
|
561
|
+
const directory = useSessionDirectory(ctx, sessionId)
|
|
562
|
+
const [open, setOpen] = React.useState(false), [catalog, setCatalog] = React.useState(null)
|
|
563
|
+
const [capability, setCapability] = React.useState(null)
|
|
564
|
+
const [create, setCreate] = React.useState(false), [baseRef, setBaseRef] = React.useState('HEAD')
|
|
565
|
+
const [environmentId, setEnvironmentId] = React.useState(''), [error, setError] = React.useState('')
|
|
566
|
+
const root = React.useRef(null), queue = React.useRef(Promise.resolve())
|
|
567
|
+
React.useLayoutEffect(() => {
|
|
568
|
+
if (!open) return
|
|
569
|
+
const popover = root.current?.querySelector('.wt-popover')
|
|
570
|
+
if (!popover) return
|
|
571
|
+
const position = () => {
|
|
572
|
+
const anchor = root.current.getBoundingClientRect()
|
|
573
|
+
const above = Math.max(0, anchor.top - 56)
|
|
574
|
+
const below = Math.max(0, window.innerHeight - anchor.bottom - 24)
|
|
575
|
+
const height = popover.scrollHeight + 2
|
|
576
|
+
const upwards = above >= height || above >= below
|
|
577
|
+
popover.style.bottom = upwards ? 'calc(100% + 10px)' : 'auto'
|
|
578
|
+
popover.style.top = upwards ? 'auto' : 'calc(100% + 10px)'
|
|
579
|
+
popover.style.maxHeight = Math.max(80, upwards ? above : below) + 'px'
|
|
580
|
+
}
|
|
581
|
+
position()
|
|
582
|
+
window.addEventListener('resize', position)
|
|
583
|
+
window.addEventListener('scroll', position, true)
|
|
584
|
+
return () => { window.removeEventListener('resize', position); window.removeEventListener('scroll', position, true) }
|
|
585
|
+
}, [open, create, catalog, error])
|
|
586
|
+
React.useEffect(() => {
|
|
587
|
+
setCatalog(null); setError(''); setOpen(false)
|
|
588
|
+
}, [sessionId, directory])
|
|
589
|
+
React.useEffect(() => {
|
|
590
|
+
if (!sessionId) return
|
|
591
|
+
const controller = new AbortController()
|
|
592
|
+
call('capability', { sessionId }, controller.signal).then(value => {
|
|
593
|
+
setCapability({ sessionId, directory, ...value })
|
|
594
|
+
if (!value.available) setOpen(false)
|
|
595
|
+
}).catch(() => { if (!controller.signal.aborted) setCapability({ sessionId, directory, available: false }) })
|
|
596
|
+
return () => controller.abort()
|
|
597
|
+
}, [sessionId, directory])
|
|
598
|
+
React.useEffect(() => {
|
|
599
|
+
if (!open || !sessionId) return
|
|
600
|
+
const controller = new AbortController()
|
|
601
|
+
call('catalog', { sessionId }, controller.signal).then(value => {
|
|
602
|
+
setCatalog(value)
|
|
603
|
+
setCreate(Boolean(value.environments.draft?.create))
|
|
604
|
+
setBaseRef(value.environments.draft?.baseRef || 'HEAD')
|
|
605
|
+
setEnvironmentId(value.environments.selectedId)
|
|
606
|
+
}).catch(cause => { if (!controller.signal.aborted) setError(cause.message) })
|
|
607
|
+
return () => controller.abort()
|
|
608
|
+
}, [open, sessionId, directory])
|
|
609
|
+
React.useEffect(() => {
|
|
610
|
+
if (!open) return
|
|
611
|
+
const close = event => { if (!root.current?.contains(event.target)) setOpen(false) }
|
|
612
|
+
const escape = event => { if (event.key === 'Escape') setOpen(false) }
|
|
613
|
+
document.addEventListener('pointerdown', close); document.addEventListener('keydown', escape)
|
|
614
|
+
return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', escape) }
|
|
615
|
+
}, [open])
|
|
616
|
+
function update(next) {
|
|
617
|
+
setCreate(next.create); setBaseRef(next.baseRef); setEnvironmentId(next.environmentId); setError('')
|
|
618
|
+
queue.current = queue.current.catch(() => {}).then(() => call('environment-select', {
|
|
619
|
+
sessionId, kind: 'draft', id: next.environmentId, draft: { create: next.create, baseRef: next.baseRef },
|
|
620
|
+
})).catch(cause => { setError(cause.message) })
|
|
621
|
+
}
|
|
622
|
+
if (!sessionId || capability?.sessionId !== sessionId || capability.directory !== directory || !capability.available) return null
|
|
623
|
+
return h('div', { className: 'wt-hero', ref: root },
|
|
624
|
+
h('button', { type: 'button', className: 'wt-trigger wt-gear', title: '新对话设置', 'aria-label': '新对话工作树与环境设置', 'aria-expanded': open, onClick: () => setOpen(value => !value) }, h(SettingsIcon, { name: 'settings' })),
|
|
625
|
+
open ? h('div', { className: 'wt-popover', role: 'dialog', 'aria-label': '新对话工作树设置' },
|
|
626
|
+
h('div', { className: 'wt-settings-group' },
|
|
627
|
+
h('label', { className: 'wt-setting-row' },
|
|
628
|
+
h('span', { className: 'wt-setting-label' }, h(SettingsIcon, { name: 'worktree' }), '新建工作树'),
|
|
629
|
+
h('span', { className: 'wt-switch' }, h('input', { type: 'checkbox', role: 'switch', checked: create, disabled: !catalog, onChange: event => update({ create: event.target.checked, baseRef, environmentId }) }), h('span', { className: 'wt-switch-track', 'aria-hidden': true }))),
|
|
630
|
+
h('label', { className: 'wt-setting-row', 'data-disabled': !create || !catalog },
|
|
631
|
+
h('span', { className: 'wt-setting-label' }, h(SettingsIcon, { name: 'branch' }), '基础分支'),
|
|
632
|
+
h('span', { className: 'wt-select-wrap' }, h('select', { value: baseRef, disabled: !create || !catalog, onChange: event => update({ create, baseRef: event.target.value, environmentId }) },
|
|
633
|
+
h('option', { value: 'HEAD' }, '当前提交 (HEAD)'), ...(catalog?.branches || []).map(ref => h('option', { key: ref, value: ref }, ref))), h(SettingsIcon, { name: 'chevron' }))),
|
|
634
|
+
h('label', { className: 'wt-setting-row' },
|
|
635
|
+
h('span', { className: 'wt-setting-label' }, h(SettingsIcon, { name: 'environment' }), '环境'),
|
|
636
|
+
h('span', { className: 'wt-select-wrap' }, h('select', { value: environmentId, disabled: !catalog, onChange: event => update({ create, baseRef, environmentId: event.target.value }) },
|
|
637
|
+
h('option', { value: '' }, '无环境'), ...(catalog?.environments?.profiles || []).map(item => h('option', { key: item.id, value: item.id }, item.name))), h(SettingsIcon, { name: 'chevron' })))),
|
|
638
|
+
!catalog && !error ? h('div', { className: 'wt-muted', role: 'status', style: { padding: '4px 6px' } }, '正在读取…') : null,
|
|
639
|
+
error ? h('span', { className: 'wt-error', role: 'alert' }, error) : null,
|
|
640
|
+
h('div', { className: 'wt-popover-footer' }, h('button', { className: 'wt-details', type: 'button', onClick: () => { setOpen(false); ctx.sidebarRight.openTabIn(sessionId, KIND) } }, '详细设置', h(SettingsIcon, { name: 'arrow' }))),
|
|
641
|
+
) : null,
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Wrap the shipped turn-tail renderer so its own access and unavailable
|
|
646
|
+
// checks still own the branch button; only the click destination changes.
|
|
647
|
+
function installForkChoices(ctx) {
|
|
648
|
+
const slots = ctx.slots, originalKey = Symbol.for('cordis.original')
|
|
649
|
+
let patch = null, stopped = false, changing = false
|
|
650
|
+
function restore() {
|
|
651
|
+
if (!patch) return
|
|
652
|
+
const { entry, descriptor, wrapper } = patch
|
|
653
|
+
patch = null
|
|
654
|
+
if (entry.component === wrapper) Object.defineProperty(entry, 'component', descriptor)
|
|
655
|
+
}
|
|
656
|
+
function invalidate() {
|
|
657
|
+
const dispose = slots.register({ name: 'conversation.chat.node', key: 'worktree-control.invalidate', priority: 10000 }, () => null)
|
|
658
|
+
dispose()
|
|
659
|
+
}
|
|
660
|
+
function reconcile() {
|
|
661
|
+
if (stopped || changing) return
|
|
662
|
+
changing = true
|
|
663
|
+
try {
|
|
664
|
+
const candidate = slots.entriesOfSlot('conversation.chat.node').find(entry => entry.options?.key === 'turn-tail')
|
|
665
|
+
const entry = candidate?.[originalKey] ?? candidate
|
|
666
|
+
if (patch && patch.entry === entry && entry.component === patch.wrapper) return
|
|
667
|
+
restore()
|
|
668
|
+
if (!entry) return
|
|
669
|
+
if (entry.component?.$$typeof !== Symbol.for('react.memo') || typeof entry.inject !== 'function' || !entry.children) throw new Error('unsupported-turn-tail-entry')
|
|
670
|
+
const descriptor = Object.getOwnPropertyDescriptor(entry, 'component')
|
|
671
|
+
if (!descriptor || (!descriptor.writable && !descriptor.configurable)) throw new Error('unsupported-turn-tail-component')
|
|
672
|
+
const NativeTail = entry.component
|
|
673
|
+
function ForkChoiceTail(props) {
|
|
674
|
+
const root = React.useRef(null)
|
|
675
|
+
const [seq, setSeq] = React.useState(null)
|
|
676
|
+
const [capability, setCapability] = React.useState(null)
|
|
677
|
+
const [busy, setBusy] = React.useState(false)
|
|
678
|
+
const [error, setError] = React.useState('')
|
|
679
|
+
const [childId, setChildId] = React.useState(null)
|
|
680
|
+
React.useEffect(() => {
|
|
681
|
+
if (seq === null) return
|
|
682
|
+
let active = true
|
|
683
|
+
call('fork-capability', { sessionId: props.sessionId }).then(value => { if (active) setCapability(value) }, cause => {
|
|
684
|
+
if (active) setCapability({ available: false, reason: cause.message })
|
|
685
|
+
})
|
|
686
|
+
return () => { active = false }
|
|
687
|
+
}, [seq, props.sessionId])
|
|
688
|
+
React.useEffect(() => {
|
|
689
|
+
if (seq === null) return
|
|
690
|
+
const outside = event => { if (!root.current?.contains(event.target) && !busy) setSeq(null) }
|
|
691
|
+
const escape = event => { if (event.key === 'Escape' && !busy) setSeq(null) }
|
|
692
|
+
document.addEventListener('pointerdown', outside)
|
|
693
|
+
document.addEventListener('keydown', escape)
|
|
694
|
+
return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape) }
|
|
695
|
+
}, [seq, busy])
|
|
696
|
+
async function forkInWorktree() {
|
|
697
|
+
if (busy || seq === null || !capability?.available) return
|
|
698
|
+
setBusy(true); setError(''); setChildId(null)
|
|
699
|
+
let createdId = null
|
|
700
|
+
try {
|
|
701
|
+
createdId = await ctx.sessions.fork({ sessionId: props.sessionId, atSeq: seq, increaseTitle: true })
|
|
702
|
+
setChildId(createdId)
|
|
703
|
+
await call('create', { sessionId: createdId, sourceSessionId: props.sessionId, forkAtSeq: seq, baseRef: 'HEAD', includeChanges: true, runSetup: false })
|
|
704
|
+
await ctx.uiWorkspace.openSession(createdId)
|
|
705
|
+
setSeq(null)
|
|
706
|
+
} catch (cause) {
|
|
707
|
+
setError((createdId ? '对话已创建,但工作树未完成:' : '创建分支失败:') + String(cause?.message ?? cause))
|
|
708
|
+
} finally { setBusy(false) }
|
|
709
|
+
}
|
|
710
|
+
return h('div', { ref: root, className: 'wt-fork-anchor' },
|
|
711
|
+
h(NativeTail, { ...props, forkAt: at => { setSeq(at); setCapability(null); setError(''); setChildId(null) } }),
|
|
712
|
+
seq === null ? null : h('div', { className: 'wt-fork-menu', role: 'dialog', 'aria-label': '从这里创建对话分支' },
|
|
713
|
+
h('strong', null, '从这里创建对话分支'),
|
|
714
|
+
h('button', { type: 'button', className: 'wt-fork-option', disabled: busy, onClick: () => { setSeq(null); props.forkAt(seq) } }, '在当前工作区中创建分支', h('small', null, '从这条回复继续,沿用当前工作目录')),
|
|
715
|
+
h('button', { type: 'button', className: 'wt-fork-option', disabled: busy || !capability?.available, onClick: forkInWorktree }, '在新工作树中创建分支', h('small', null, capability === null ? '正在检查 Git 工作区…' : capability.available ? '立即创建工作树,复制当前未提交改动' : capability.reason)),
|
|
716
|
+
error ? h('p', { className: 'wt-error', role: 'alert' }, error) : null,
|
|
717
|
+
childId && error ? h('button', { type: 'button', className: 'wt-fork-option', onClick: () => { void ctx.uiWorkspace.openSession(childId); setSeq(null) } }, '打开已创建的对话') : null,
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
}
|
|
721
|
+
Object.defineProperty(entry, 'component', { ...descriptor, value: ForkChoiceTail })
|
|
722
|
+
patch = { entry, descriptor, wrapper: ForkChoiceTail }
|
|
723
|
+
invalidate()
|
|
724
|
+
} catch (cause) { console.warn('worktree-control: fork choices unavailable', cause) }
|
|
725
|
+
finally { changing = false }
|
|
726
|
+
}
|
|
727
|
+
return slots.inject('conversation.chat.node', () => {
|
|
728
|
+
const unsubscribe = slots.subscribe('conversation.chat.node', reconcile)
|
|
729
|
+
reconcile()
|
|
730
|
+
return () => { stopped = true; unsubscribe(); changing = true; try { restore(); invalidate() } finally { changing = false } }
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// The shipped Open In App control and dsh-git-branch-manage both read the
|
|
735
|
+
// immutable session-list cwd. Keep their actions and menus, but supply the
|
|
736
|
+
// live working directory after a conversation moves to a worktree.
|
|
737
|
+
function mirrorDirectorySlot(ctx, slot, id) {
|
|
738
|
+
const slots = ctx.slots
|
|
739
|
+
let source, dispose = null, queued = false, stopped = false
|
|
740
|
+
function CurrentDirectoryControl(props) {
|
|
741
|
+
const directory = props.useProjection('workingDirectory')
|
|
742
|
+
const useSessions = React.useCallback((selector, equal) => props.useSessions(snapshot => {
|
|
743
|
+
const summary = snapshot.byId[props.sessionId]
|
|
744
|
+
if (typeof directory !== 'string' || !summary || summary.cwd === directory) return selector(snapshot)
|
|
745
|
+
return selector({ ...snapshot, byId: { ...snapshot.byId, [props.sessionId]: { ...summary, cwd: directory } } })
|
|
746
|
+
}, equal), [props.useSessions, props.sessionId, directory])
|
|
747
|
+
return source ? h(source.component, { ...props, useSessions }) : null
|
|
748
|
+
}
|
|
749
|
+
function reconcile() {
|
|
750
|
+
const next = slots.entries(slot).find(entry => entry.options.id === id && entry.component !== CurrentDirectoryControl)
|
|
751
|
+
if (next === source) return
|
|
752
|
+
dispose?.()
|
|
753
|
+
dispose = null
|
|
754
|
+
source = next
|
|
755
|
+
if (!next) return
|
|
756
|
+
const options = { name: slot, id, order: next.options.order ?? 0, priority: -100,
|
|
757
|
+
...(next.locale ? { locale: next.locale } : {}),
|
|
758
|
+
...(next.inject ? { inject: next.inject } : {}) }
|
|
759
|
+
dispose = slots.register(options, CurrentDirectoryControl)
|
|
760
|
+
}
|
|
761
|
+
function schedule() {
|
|
762
|
+
if (queued || stopped) return
|
|
763
|
+
queued = true
|
|
764
|
+
queueMicrotask(() => { queued = false; if (!stopped) reconcile() })
|
|
765
|
+
}
|
|
766
|
+
const unsubscribe = slots.subscribe(slot, schedule)
|
|
767
|
+
reconcile()
|
|
768
|
+
return () => { stopped = true; unsubscribe(); dispose?.() }
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
inject: ['slots', 'sidebarRight', 'sidebarRightTabs', 'sessions', 'uiWorkspace'],
|
|
773
|
+
apply(ctx) {
|
|
774
|
+
ctx.effect(() => installEnvironmentTerminals(ctx), 'worktree-control: terminal jobs')
|
|
775
|
+
ctx.effect(() => { const style = document.createElement('style'); style.dataset.plugin = 'dsh-asuta-worktree-control'; style.textContent = CSS; document.head.appendChild(style); return () => style.remove() }, 'worktree-control: styles')
|
|
776
|
+
ctx.effect(() => ctx.sidebarRightTabs.register({
|
|
777
|
+
id: TAB_ID, kind: KIND, patterns: ['sidebar://worktree-control', 'sidebar://worktree-control/**'], title: () => '工作树',
|
|
778
|
+
guide: [{ id: 'new', order: 43, title: () => '工作树', description: () => '创建、切换、快照和恢复 Git 工作树' }],
|
|
779
|
+
}), 'worktree-control: tab type')
|
|
780
|
+
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: TAB_ID }, props => h(Panel, { ...props, ctx }))), 'worktree-control: panel')
|
|
781
|
+
ctx.effect(() => ctx.slots.inject('conversation.hero.options', () => ctx.slots.register({ name: 'conversation.hero.options', id: 'worktree-control.hero', order: 25 }, props => h(HeroSettings, { ...props, ctx }))), 'worktree-control: hero settings')
|
|
782
|
+
ctx.effect(() => ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({ name: 'sidebar.workspaces.session.row.action', id: 'worktree-control.badge', order: 900 }, props => h(RowBadge, { ...props, ctx }))), 'worktree-control: sidebar badges')
|
|
783
|
+
ctx.effect(() => ctx.slots.inject('conversation.session.header.utilities', () => mirrorDirectorySlot(ctx, 'conversation.session.header.utilities', 'open-in-app')), 'worktree-control: current folder')
|
|
784
|
+
ctx.effect(() => ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ name: 'conversation.session.header.utilities', id: 'worktree-control.commands', order: -7 }, props => h(CommandMenuPlacement, { ...props, ctx }))), 'worktree-control: environment commands')
|
|
785
|
+
ctx.effect(() => ctx.slots.inject('conversation.input.left', () => mirrorDirectorySlot(ctx, 'conversation.input.left', 'git-worktree')), 'worktree-control: current branch')
|
|
786
|
+
ctx.effect(() => installForkChoices(ctx), 'worktree-control: fork choices')
|
|
787
|
+
},
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
})
|