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 worktreeDirectory: string worktreeOpenWith: 'vscode' | 'pycharm' pycharmCommand: string worktreePostCreateScript: string worktreePreRemoveScript: string implTabPreCreateScript: string implTabPostCloseScript: string conflictResolutionProfilePath: string youtrackBaseUrl: string youtrackProjectShortName: string youtrackCloseCommand: string /** Empty string = keep the existing stored YouTrack token (placeholder). */ youtrackToken: string }): Promise { 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() // worktree dir template: '' = "use default", so don't preserve blanks // specially — getSettings falls back to the default at read time. const trimmedWorktreeDir = payload.worktreeDirectory.trim() // pycharm launcher command: '' = "use default `pycharm`", getSettings falls // back at read time, so just strip whitespace here. const trimmedPycharmCmd = payload.pycharmCommand.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() // conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH). const trimmedConflictProfile = payload.conflictResolutionProfilePath.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, worktreeDirectory: trimmedWorktreeDir || prev.worktreeDirectory, worktreeOpenWith: payload.worktreeOpenWith || prev.worktreeOpenWith, pycharmCommand: trimmedPycharmCmd || prev.pycharmCommand, worktreePostCreateScript: trimmedPostCreate, worktreePreRemoveScript: trimmedPreRemove, implTabPreCreateScript: trimmedImplPre, implTabPostCloseScript: trimmedImplPost, conflictResolutionProfilePath: trimmedConflictProfile, }) 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, worktreeDirectory: trimmedWorktreeDir, worktreeOpenWith: payload.worktreeOpenWith, pycharmCommand: trimmedPycharmCmd, worktreePostCreateScript: trimmedPostCreate, worktreePreRemoveScript: trimmedPreRemove, implTabPreCreateScript: trimmedImplPre, implTabPostCloseScript: trimmedImplPost, conflictResolutionProfilePath: trimmedConflictProfile, 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 { 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, worktreeDirectory: s.worktreeDirectory, worktreeOpenWith: s.worktreeOpenWith, pycharmCommand: s.pycharmCommand, worktreePostCreateScript: s.worktreePostCreateScript, worktreePreRemoveScript: s.worktreePreRemoveScript, implTabPreCreateScript: s.implTabPreCreateScript, implTabPostCloseScript: s.implTabPostCloseScript, conflictResolutionProfilePath: s.conflictResolutionProfilePath, 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 { 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 { 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 { 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 { 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, }) } }