This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+441
View File
@@ -0,0 +1,441 @@
import type { ProfilesData } from '../../profiles/store'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { commands, env, Uri, workspace } from 'vscode'
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
import { listClaudeProfiles } from '../../cc/profiles'
import { detectRepo } from '../../git/remote'
import { logger } from '../../logging/logger'
import { readProfiles, writeProfiles } from '../../profiles/store'
import { getSettings, saveSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator'
import { youtrackHost } from '../../youtrack/issueLoader'
import { makeNonce } from '../KanbanPanel'
export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
host: string
token: string
webhookPort: number
brainstormPrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
devBranch: string
autoBuildBranch: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: string
youtrackBaseUrl: string
youtrackProjectShortName: string
youtrackCloseCommand: string
/** Empty string = keep the existing stored YouTrack token (placeholder). */
youtrackToken: string
}): Promise<void> {
const trimmedHost = payload.host.trim()
const trimmedToken = payload.token.trim()
const trimmedYtBase = payload.youtrackBaseUrl.trim()
const trimmedYtProject = payload.youtrackProjectShortName.trim()
const trimmedYtClose = payload.youtrackCloseCommand.trim()
const trimmedYtToken = payload.youtrackToken.trim()
const trimmedDevBranch = payload.devBranch.trim()
const trimmedAutoBuildBranch = payload.autoBuildBranch.trim()
// Hook script paths: keep '' meaningful (= "use default
// .spx/*.sh"). Just strip whitespace.
const trimmedPostCreate = payload.worktreePostCreateScript.trim()
const trimmedPreRemove = payload.worktreePreRemoveScript.trim()
const trimmedImplPre = payload.implTabPreCreateScript.trim()
const trimmedImplPost = payload.implTabPostCloseScript.trim()
const prev = getSettings(panel.context)
// Capture the previous token *for this host* before overwriting it, so
// we can decide below whether the kanban needs a re-fetch. (Only host
// and token affect the issue list — port/url-prefix/prompts don't.)
const oldToken = trimmedHost ? await getToken(panel.context, trimmedHost) : undefined
// Empty token + existing saved token = user wants to keep the existing
// one (placeholder semantics in the modal). Skip rewriting and skip the
// kanban refresh since auth didn't change.
const keepExisting = trimmedToken === '' && !!oldToken
if (!trimmedHost || (!trimmedToken && !keepExisting)) {
panel.postMessage({
type: 'settings/show',
host: trimmedHost,
errorMessage: 'Host 和 Token 都不能为空',
tokenSaved: !!oldToken,
webhookPort: payload.webhookPort,
brainstormPrompt: payload.brainstormPrompt || prev.brainstormPrompt,
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
autoReview: payload.autoReview,
reviewPrompt: payload.reviewPrompt || prev.reviewPrompt,
devBranch: trimmedDevBranch || prev.devBranch,
autoBuildBranch: trimmedAutoBuildBranch,
worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre,
implTabPostCloseScript: trimmedImplPost,
})
return
}
await saveSettings(panel.context, {
webhookPort: payload.webhookPort,
brainstormPrompt: payload.brainstormPrompt,
implementPlanPrompt: payload.implementPlanPrompt,
autoReview: payload.autoReview,
reviewPrompt: payload.reviewPrompt,
// Persist trimmed values; '' is meaningful for autoBuildBranch
// ("follow devBranch"), so don't coerce — getSettings handles the
// fallback at read time.
devBranch: trimmedDevBranch,
autoBuildBranch: trimmedAutoBuildBranch,
worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre,
implTabPostCloseScript: trimmedImplPost,
youtrackBaseUrl: trimmedYtBase,
youtrackProjectShortName: trimmedYtProject,
youtrackCloseCommand: trimmedYtClose,
})
if (!keepExisting)
await setToken(panel.context, trimmedHost, trimmedToken)
// YouTrack token: stored under the base URL's host. Empty input keeps the
// existing token (placeholder semantics), matching the gitea flow above.
if (trimmedYtBase && trimmedYtToken)
await setYouTrackToken(panel.context, youtrackHost(trimmedYtBase), trimmedYtToken)
// Honor a port change without requiring a window reload. Restart the
// listener and emit a log entry when the port actually changed so the
// user can see it in the log modal.
const newPort = getSettings(panel.context).webhookPort
if (newPort !== prev.webhookPort) {
logger.add({
level: 'info',
source: 'webhook',
message: `端口配置变更,重启监听 :${newPort}`,
})
}
try {
await webhookCoordinator.ensurePort(newPort)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'ensurePort 失败',
details: message,
})
}
// Only re-fetch issues when the credential that gates the kanban
// actually changed. Saves a round-trip + visible loading flash when the
// user just tweaked prompts or webhook settings. `keepExisting` already
// guarantees no auth change.
// Re-fetch when the gitea credential OR any YouTrack config changed (base
// URL / project / a freshly entered token) so newly-mirrored issues appear.
const youtrackChanged = prev.youtrackBaseUrl !== trimmedYtBase
|| prev.youtrackProjectShortName !== trimmedYtProject
|| trimmedYtToken !== ''
if ((!keepExisting && oldToken !== trimmedToken) || youtrackChanged)
await panel.loadAndPush()
// Branch-sync inputs may have changed — refresh the toolbar button.
void panel.handleBranchSyncCheck()
}
export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
let host = ''
if (workspaceRoot) {
const remote = await detectRepo(workspaceRoot)
if (remote)
host = remote.host
}
const s = getSettings(panel.context)
const tok = host ? await getToken(panel.context, host) : undefined
const tokenSaved = !!tok && tok.length > 0
const ytTok = s.youtrackBaseUrl.trim()
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
: undefined
// User clicked the gear themselves — let them back out without saving.
panel.postMessage({
type: 'settings/show',
host,
canCancel: true,
tokenSaved,
webhookPort: s.webhookPort,
brainstormPrompt: s.brainstormPrompt,
implementPlanPrompt: s.implementPlanPrompt,
autoReview: s.autoReview,
reviewPrompt: s.reviewPrompt,
devBranch: s.devBranch,
autoBuildBranch: s.autoBuildBranch,
worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript,
implTabPostCloseScript: s.implTabPostCloseScript,
youtrackBaseUrl: s.youtrackBaseUrl,
youtrackProjectShortName: s.youtrackProjectShortName,
youtrackCloseCommand: s.youtrackCloseCommand,
youtrackTokenSaved: !!ytTok && ytTok.length > 0,
})
}
/** Forces the open panel (if any) into the setup-auth state. */
export function requestEditAuth(panel: KanbanWebviewPanel): void {
void handleEditSettingsRequest(panel)
}
/**
* 工作区 profile 表读取入口:webview mount 时 / 切换到 Profile tab 时拉数据。
* 文件不存在直接返回默认结构 `{ profiles: ['dev', 'prod'], rows: [] }`
* 不会物理创建文件。
*/
export async function handleProfilesGet(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
return
}
try {
const data = await readProfiles(workspaceRoot)
panel.postMessage({ type: 'profiles/show', data })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `读取 profiles.json 失败: ${message}`,
dismissOnTimer: 6000,
})
}
}
/**
* Read profiles from the hardcoded directory and push the list to the
* webview. Failures are swallowed and surfaced as an empty list so the
* modal simply hides its profile selector.
*/
export async function handleProfilesList(panel: KanbanWebviewPanel): Promise<void> {
try {
const profiles = await listClaudeProfiles()
panel.postMessage({ type: 'profiles/update', profiles })
}
catch {
panel.postMessage({ type: 'profiles/update', profiles: [] })
}
}
/**
* 智能打开 profile 单元格里的 value。
*
* - http(s):// / git@host:owner/repo → 外部浏览器(git@ 先转 https
* - 其他 → 当作路径处理,`~` 展开为 home,相对路径以 workspaceRoot 为基
* - 是目录 → revealFileInOS
* - 是文件 → vscode.open
* - 不存在 → toast 报错
*/
/**
* Lower-cased extensions (with leading dot) that should open inside the
* VS Code editor. Anything not in this set — and files with no extension
* are treated as text too — is handed to the OS default application.
*/
const TEXT_OPEN_EXTENSIONS = new Set([
'.md', '.markdown', '.txt', '.text', '.csv', '.tsv', '.json', '.jsonc',
'.yaml', '.yml', '.toml', '.ini', '.conf', '.cfg', '.log', '.xml', '.html',
'.htm', '.css', '.scss', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
'.vue', '.py', '.go', '.rs', '.java', '.kt', '.c', '.h', '.cpp', '.hpp',
'.cc', '.sh', '.bash', '.zsh', '.sql', '.env', '.properties', '.gradle',
'.dockerfile', '.gitignore',
])
/**
* Open a file with the OS default application (fire-and-forget).
*
* Dispatches per platform without a shell so the path cannot be
* interpreted as a command. On Windows the first empty `start` argument is
* the window-title placeholder required by `cmd`'s `start` builtin.
*/
function openWithSystemDefault(absPath: string): void {
let command: string
let args: string[]
switch (process.platform) {
case 'darwin':
command = 'open'
args = [absPath]
break
case 'win32':
command = 'cmd'
args = ['/c', 'start', '', absPath]
break
default:
command = 'xdg-open'
args = [absPath]
break
}
execFile(command, args, (err) => {
if (err) {
logger.add({
level: 'error',
source: 'profiles',
message: `系统默认程序打开失败: ${absPath}`,
details: err.message,
})
}
})
}
export async function handleProfilesOpen(panel: KanbanWebviewPanel, value: string): Promise<void> {
const trimmed = value.trim()
if (!trimmed) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '无法打开:值为空',
dismissOnTimer: 5000,
})
return
}
// URL 分支:http(s) 直开;git@ 先转 https
if (/^(?:https?:\/\/|git@)/i.test(trimmed)) {
let url = trimmed
const gitSshMatch = /^git@([^:]+):(.+?)(?:\.git)?$/i.exec(trimmed)
if (gitSshMatch) {
const host = gitSshMatch[1]
const repoPath = gitSshMatch[2].replace(/\.git$/i, '')
url = `https://${host}/${repoPath}`
}
else if (/^https?:\/\//i.test(trimmed)) {
url = trimmed.replace(/\.git$/i, '')
}
try {
await env.openExternal(Uri.parse(url))
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `打开链接失败: ${message}`,
dismissOnTimer: 6000,
})
}
return
}
// 路径分支:~ 展开、相对路径补 workspaceRoot
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
let abs: string
if (trimmed.startsWith('~')) {
abs = path.join(os.homedir(), trimmed.slice(1))
}
else if (path.isAbsolute(trimmed)) {
abs = trimmed
}
else {
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '无法解析相对路径:未打开工作区',
dismissOnTimer: 5000,
})
return
}
abs = path.join(workspaceRoot, trimmed)
}
let stat: fs.Stats
try {
stat = fs.statSync(abs)
}
catch {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `无法打开:路径不存在 / 不是 URL(${abs}`,
dismissOnTimer: 6000,
})
return
}
try {
if (stat.isDirectory()) {
await commands.executeCommand('revealFileInOS', Uri.file(abs))
}
else if (stat.isFile()) {
const ext = path.extname(abs).toLowerCase()
// No extension counts as text. Text/code extensions open in the
// editor; everything else (archives, office docs, media, …) is
// handed to the OS default application so binary content is not
// garbled by VS Code's text viewer.
if (ext === '' || TEXT_OPEN_EXTENSIONS.has(ext)) {
await commands.executeCommand('vscode.open', Uri.file(abs))
}
else {
openWithSystemDefault(abs)
}
}
else {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `无法打开:既不是文件也不是目录(${abs}`,
dismissOnTimer: 6000,
})
}
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `打开失败: ${message}`,
dismissOnTimer: 6000,
})
}
}
/**
* 工作区 profile 表写入入口。webview 已经 optimistic 更新,
* 成功不回消息;失败弹 toast。
*/
export async function handleProfilesSave(panel: KanbanWebviewPanel, data: ProfilesData): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
return
}
try {
await writeProfiles(workspaceRoot, data)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `写入 profiles.json 失败: ${message}`,
dismissOnTimer: 6000,
})
}
}