三类会话的 profile 此前只有工单级字段,留空就硬编码落到 official, 想整体换模型只能一个工单一个工单改。 设置面板新增三个下拉,与冲突解决/提交/改动说明的 profile 选择同区。 解析链路变成:工单级字段 > 对应的全局默认设置 > getDefaultProfilePath(), 三类之间仍互不回退。新建工单的头脑风暴会话也走同一条链路。 Claude-Session: https://claude.ai/code/session_01REdhpbaNyqWASF2HWsnTFv
585 lines
21 KiB
TypeScript
585 lines
21 KiB
TypeScript
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 process from 'node:process'
|
||
import { commands, env, Uri, workspace } from 'vscode'
|
||
import { clearIdentityCache, initIdentity } from '../../auth/identity'
|
||
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
|
||
import { listClaudeProfiles, setProfilesDirOverride } from '../../cc/profiles'
|
||
import { detectRepo } from '../../git/remote'
|
||
import { logger } from '../../logging/logger'
|
||
import { readProfiles, writeProfiles } from '../../profiles/store'
|
||
import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, 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
|
||
webhookSecret: string
|
||
brainstormPrompt: string
|
||
brainstormContinuePrompt: string
|
||
implementPlanPrompt: string
|
||
autoReview: boolean
|
||
reviewPrompt: string
|
||
devBranch: string
|
||
releaseBranchPrefix: string
|
||
jenkinsJob: string
|
||
jenkinsProdHost: string
|
||
worktreeDirectory: string
|
||
worktreeOpenWith: 'vscode' | 'pycharm'
|
||
pycharmCommand: string
|
||
worktreePostCreateScript: string
|
||
worktreePreRemoveScript: string
|
||
implTabPreCreateScript: string
|
||
implTabPostCloseScript: string
|
||
splitNewTerminals: boolean
|
||
defaultBrainstormProfilePath: string
|
||
defaultImplementProfilePath: string
|
||
defaultTestProfilePath: string
|
||
conflictResolutionProfilePath: string
|
||
commitProfilePath: string
|
||
prDiffSummaryProfilePath: string
|
||
codexModel: string
|
||
codexReasoningEffort: string
|
||
profilesDirectory: 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()
|
||
// 发布前缀 / Jenkins 任务 / 正式服 host:只 trim,空值回退在 getSettings 读时做。
|
||
const trimmedReleasePrefix = payload.releaseBranchPrefix.trim()
|
||
const trimmedJenkinsJob = payload.jenkinsJob.trim()
|
||
const trimmedJenkinsProdHost = payload.jenkinsProdHost.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()
|
||
// 三个会话默认 profile:'' 有意义(= 落到 DEFAULT_PROFILE_PATH),只 trim。
|
||
const trimmedDefaultBrainstormProfile = payload.defaultBrainstormProfilePath.trim()
|
||
const trimmedDefaultImplementProfile = payload.defaultImplementProfilePath.trim()
|
||
const trimmedDefaultTestProfile = payload.defaultTestProfilePath.trim()
|
||
// conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH).
|
||
const trimmedConflictProfile = payload.conflictResolutionProfilePath.trim()
|
||
// 提交代码 profile:'' 有意义(= 未配置 → 提交严格失败),只 trim。
|
||
const trimmedCommitProfile = payload.commitProfilePath.trim()
|
||
const trimmedPrDiffSummaryProfile = payload.prDiffSummaryProfilePath.trim()
|
||
// codex 模型 / 思考级别:空串有意义(= 用 codex 默认),只 trim。
|
||
const trimmedCodexModel = payload.codexModel.trim()
|
||
const trimmedCodexReasoningEffort = payload.codexReasoningEffort.trim()
|
||
// profiles 目录:空串有意义(= 自动探测),只 trim。
|
||
const trimmedProfilesDirectory = payload.profilesDirectory.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,
|
||
webhookSecret: payload.webhookSecret,
|
||
brainstormPrompt: payload.brainstormPrompt || prev.brainstormPrompt,
|
||
brainstormContinuePrompt: payload.brainstormContinuePrompt || prev.brainstormContinuePrompt,
|
||
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
|
||
autoReview: payload.autoReview,
|
||
reviewPrompt: payload.reviewPrompt || prev.reviewPrompt,
|
||
devBranch: trimmedDevBranch || prev.devBranch,
|
||
releaseBranchPrefix: trimmedReleasePrefix || prev.releaseBranchPrefix,
|
||
jenkinsJob: trimmedJenkinsJob,
|
||
jenkinsProdHost: trimmedJenkinsProdHost || prev.jenkinsProdHost,
|
||
worktreeDirectory: trimmedWorktreeDir || prev.worktreeDirectory,
|
||
worktreeOpenWith: payload.worktreeOpenWith || prev.worktreeOpenWith,
|
||
pycharmCommand: trimmedPycharmCmd || prev.pycharmCommand,
|
||
worktreePostCreateScript: trimmedPostCreate,
|
||
worktreePreRemoveScript: trimmedPreRemove,
|
||
implTabPreCreateScript: trimmedImplPre,
|
||
implTabPostCloseScript: trimmedImplPost,
|
||
splitNewTerminals: payload.splitNewTerminals,
|
||
defaultBrainstormProfilePath: trimmedDefaultBrainstormProfile,
|
||
defaultImplementProfilePath: trimmedDefaultImplementProfile,
|
||
defaultTestProfilePath: trimmedDefaultTestProfile,
|
||
conflictResolutionProfilePath: trimmedConflictProfile,
|
||
commitProfilePath: trimmedCommitProfile,
|
||
prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile,
|
||
codexModel: trimmedCodexModel,
|
||
codexReasoningEffort: trimmedCodexReasoningEffort,
|
||
profilesDirectory: trimmedProfilesDirectory,
|
||
})
|
||
return
|
||
}
|
||
await saveSettings(panel.context, {
|
||
webhookPort: payload.webhookPort,
|
||
webhookSecret: payload.webhookSecret.trim(),
|
||
brainstormPrompt: payload.brainstormPrompt,
|
||
brainstormContinuePrompt: payload.brainstormContinuePrompt,
|
||
implementPlanPrompt: payload.implementPlanPrompt,
|
||
autoReview: payload.autoReview,
|
||
reviewPrompt: payload.reviewPrompt,
|
||
devBranch: trimmedDevBranch,
|
||
releaseBranchPrefix: trimmedReleasePrefix,
|
||
jenkinsJob: trimmedJenkinsJob,
|
||
jenkinsProdHost: trimmedJenkinsProdHost,
|
||
worktreeDirectory: trimmedWorktreeDir,
|
||
worktreeOpenWith: payload.worktreeOpenWith,
|
||
pycharmCommand: trimmedPycharmCmd,
|
||
worktreePostCreateScript: trimmedPostCreate,
|
||
worktreePreRemoveScript: trimmedPreRemove,
|
||
implTabPreCreateScript: trimmedImplPre,
|
||
implTabPostCloseScript: trimmedImplPost,
|
||
splitNewTerminals: payload.splitNewTerminals,
|
||
defaultBrainstormProfilePath: trimmedDefaultBrainstormProfile,
|
||
defaultImplementProfilePath: trimmedDefaultImplementProfile,
|
||
defaultTestProfilePath: trimmedDefaultTestProfile,
|
||
conflictResolutionProfilePath: trimmedConflictProfile,
|
||
commitProfilePath: trimmedCommitProfile,
|
||
prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile,
|
||
codexModel: trimmedCodexModel,
|
||
codexReasoningEffort: trimmedCodexReasoningEffort,
|
||
profilesDirectory: trimmedProfilesDirectory,
|
||
youtrackBaseUrl: trimmedYtBase,
|
||
youtrackProjectShortName: trimmedYtProject,
|
||
youtrackCloseCommand: trimmedYtClose,
|
||
})
|
||
// 保存后立刻让 listClaudeProfiles / getDefaultProfilePath 吃到新目录。
|
||
setProfilesDirOverride(trimmedProfilesDirectory || undefined)
|
||
void handleProfilesList(panel)
|
||
if (!keepExisting) {
|
||
await setToken(panel.context, trimmedHost, trimmedToken)
|
||
// 新 token 可能换了人,身份缓存和状态栏都要跟着重解析。
|
||
clearIdentityCache(trimmedHost)
|
||
void initIdentity(panel.context)
|
||
}
|
||
// 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}`,
|
||
})
|
||
}
|
||
webhookCoordinator.ensureSecret(getSettings(panel.context).webhookSecret)
|
||
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()
|
||
// 开发分支 / 发布前缀可能变了,刷新工具栏发布按钮。
|
||
void panel.handleReleaseStatus()
|
||
}
|
||
|
||
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)
|
||
// 打开设置时同步 override,避免未 activate 路径或旧值漂移。
|
||
setProfilesDirOverride(s.profilesDirectory || undefined)
|
||
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
|
||
// 升级 seed:key 缺失时预填 deepseek path(若有),仅展示不写回。
|
||
const commitProfilePath = await getEffectiveCommitProfilePath(panel.context)
|
||
const prDiffSummaryProfilePath = await getEffectivePrDiffSummaryProfilePath(panel.context)
|
||
// User clicked the gear themselves — let them back out without saving.
|
||
panel.postMessage({
|
||
type: 'settings/show',
|
||
host,
|
||
canCancel: true,
|
||
tokenSaved,
|
||
webhookPort: s.webhookPort,
|
||
webhookSecret: s.webhookSecret,
|
||
brainstormPrompt: s.brainstormPrompt,
|
||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||
implementPlanPrompt: s.implementPlanPrompt,
|
||
autoReview: s.autoReview,
|
||
reviewPrompt: s.reviewPrompt,
|
||
devBranch: s.devBranch,
|
||
releaseBranchPrefix: s.releaseBranchPrefix,
|
||
jenkinsJob: s.jenkinsJob,
|
||
jenkinsProdHost: s.jenkinsProdHost,
|
||
worktreeDirectory: s.worktreeDirectory,
|
||
worktreeOpenWith: s.worktreeOpenWith,
|
||
pycharmCommand: s.pycharmCommand,
|
||
worktreePostCreateScript: s.worktreePostCreateScript,
|
||
worktreePreRemoveScript: s.worktreePreRemoveScript,
|
||
implTabPreCreateScript: s.implTabPreCreateScript,
|
||
implTabPostCloseScript: s.implTabPostCloseScript,
|
||
splitNewTerminals: s.splitNewTerminals,
|
||
defaultBrainstormProfilePath: s.defaultBrainstormProfilePath,
|
||
defaultImplementProfilePath: s.defaultImplementProfilePath,
|
||
defaultTestProfilePath: s.defaultTestProfilePath,
|
||
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
|
||
commitProfilePath,
|
||
prDiffSummaryProfilePath,
|
||
codexModel: s.codexModel,
|
||
codexReasoningEffort: s.codexReasoningEffort,
|
||
profilesDirectory: s.profilesDirectory,
|
||
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,
|
||
})
|
||
}
|
||
}
|