commit e6f1776d4ff9e01673f93511433fd8b26a02b8cd Author: cruldra Date: Tue Jun 23 05:02:15 2026 +0800 11 diff --git a/jetbrains/.gitignore b/jetbrains/.gitignore new file mode 100644 index 0000000..2bbfe23 --- /dev/null +++ b/jetbrains/.gitignore @@ -0,0 +1,16 @@ +### Gradle ### +.gradle/ +build/ +out/ +.intellijPlatform/ + +### Kotlin ### +.kotlin/ + +### IntelliJ IDEA ### +*.iml +.idea/ +local.properties + +### Mac OS ### +.DS_Store diff --git a/jetbrains/build.gradle.kts b/jetbrains/build.gradle.kts new file mode 100644 index 0000000..3a0a919 --- /dev/null +++ b/jetbrains/build.gradle.kts @@ -0,0 +1,57 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +plugins { + id("java") + id("org.jetbrains.kotlin.jvm") version "2.4.0" + id("org.jetbrains.kotlin.plugin.compose") version "2.4.0" + id("org.jetbrains.kotlin.plugin.serialization") version "2.4.0" + id("org.jetbrains.intellij.platform") version "2.16.0" +} + +group = "com.cruldra" +version = "0.1.0" + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + intellijIdeaCommunity("2026.1.3") { + useInstaller = false + } + bundledPlugin("Git4Idea") + bundledPlugin("org.jetbrains.plugins.terminal") + testFramework(TestFrameworkType.Platform) + + // Jewel (Compose for Desktop) ships bundled with the platform since 251.2+ + bundledModule("intellij.platform.jewel.foundation") + bundledModule("intellij.platform.jewel.ui") + bundledModule("intellij.platform.jewel.ideLafBridge") + bundledModule("intellij.libraries.compose.foundation.desktop") + bundledModule("intellij.libraries.compose.runtime.desktop") + bundledModule("intellij.libraries.skiko") + } + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") { + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core") + } + + testImplementation("junit:junit:4.13.2") +} + +intellijPlatform { + pluginConfiguration { + ideaVersion { + sinceBuild = "261" + untilBuild = provider { null } + } + } +} + +kotlin { + jvmToolchain(21) +} diff --git a/jetbrains/gradle.properties b/jetbrains/gradle.properties new file mode 100644 index 0000000..ceea08a --- /dev/null +++ b/jetbrains/gradle.properties @@ -0,0 +1,5 @@ +kotlin.stdlib.default.dependency=false +org.gradle.caching=true +org.gradle.configuration-cache=false +org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC +org.gradle.parallel=true diff --git a/jetbrains/gradle/wrapper/gradle-wrapper.jar b/jetbrains/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/jetbrains/gradle/wrapper/gradle-wrapper.jar differ diff --git a/jetbrains/gradle/wrapper/gradle-wrapper.properties b/jetbrains/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..eb84db6 --- /dev/null +++ b/jetbrains/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/jetbrains/gradlew b/jetbrains/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/jetbrains/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/jetbrains/gradlew.bat b/jetbrains/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/jetbrains/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/jetbrains/settings.gradle.kts b/jetbrains/settings.gradle.kts new file mode 100644 index 0000000..949f6c2 --- /dev/null +++ b/jetbrains/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "superworkbench" diff --git a/jetbrains/src/main/kotlin/com/cruldra/superworkbench/git/WorktreeHooks.kt b/jetbrains/src/main/kotlin/com/cruldra/superworkbench/git/WorktreeHooks.kt new file mode 100644 index 0000000..12e7dff --- /dev/null +++ b/jetbrains/src/main/kotlin/com/cruldra/superworkbench/git/WorktreeHooks.kt @@ -0,0 +1,178 @@ +package com.cruldra.superworkbench.git + +import com.cruldra.superworkbench.settings.SettingsService +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.openapi.diagnostic.thisLogger +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.TimeUnit + +/** + * worktree 创建/移除生命周期前后运行用户自定义 shell 脚本,对应源项目 + * `src/git/worktreeHooks.ts`。让用户能在不改插件代码的前提下做环境准备 + * (复制 `.env`、开 IDE …)与清理(关 IDE、归档日志 …)。 + * + * 行为契约(与源一致的 fire-and-forget 语义): + * - 脚本文件不存在 → 静默返回 [HookStatus.SKIPPED](默认状态,不打扰用户)。 + * - 脚本退出 0 → [HookStatus.OK]。 + * - 非零退出 / `bash` 不在 PATH / 超时 → 返回非 OK 状态;调用方记日志, + * 但绝不中断外层流程(worktree 创建/移除才是事实来源,脚本只是旁路)。 + * + * 注入子进程的环境变量(变量名照源 worktreeHooks.ts): + * - `WORKTREE_PATH` — worktree 绝对路径 + * - `WORKSPACE_ROOT` — 主工作区绝对路径 + * - `BRANCH` — feature 分支名 + * - `ISSUE_NUMBER` — gitea 工单号(十进制字符串) + * - `MAIN_BRANCH` — settings.devBranch(默认 `main`) + */ +object WorktreeHooks { + private const val DEFAULT_POST_CREATE_REL = ".spx/worktree-post-create.sh" + private const val DEFAULT_PRE_REMOVE_REL = ".spx/worktree-pre-remove.sh" + private const val DEFAULT_IMPL_TAB_PRE_CREATE_REL = ".spx/impl-tab-pre-create.sh" + private const val DEFAULT_IMPL_TAB_POST_CLOSE_REL = ".spx/impl-tab-post-close.sh" + private const val HOOK_TIMEOUT_MS = 30_000L + + /** worktree 创建后钩子。脚本路径取 `worktreePostCreateScript`,空则用默认相对路径。 */ + fun runPostCreateHook(ctx: WorktreeContext): HookResult = + runHook(ctx, settingsScript { it.worktreePostCreateScript }, DEFAULT_POST_CREATE_REL) + + /** worktree 移除前钩子。脚本路径取 `worktreePreRemoveScript`,空则用默认相对路径。 */ + fun runPreRemoveHook(ctx: WorktreeContext): HookResult = + runHook(ctx, settingsScript { it.worktreePreRemoveScript }, DEFAULT_PRE_REMOVE_REL) + + /** 实施 tab 创建前钩子。脚本路径取 `implTabPreCreateScript`,空则用默认相对路径。 */ + fun runImplTabPreCreateHook(ctx: WorktreeContext): HookResult = + runHook(ctx, settingsScript { it.implTabPreCreateScript }, DEFAULT_IMPL_TAB_PRE_CREATE_REL) + + /** 实施 tab 关闭后钩子。脚本路径取 `implTabPostCloseScript`,空则用默认相对路径。 */ + fun runImplTabPostCloseHook(ctx: WorktreeContext): HookResult = + runHook(ctx, settingsScript { it.implTabPostCloseScript }, DEFAULT_IMPL_TAB_POST_CLOSE_REL) + + /** 从 [SettingsService] 状态里取一个自定义脚本路径覆盖(trim 后),可能为空串。 */ + private inline fun settingsScript(pick: (com.cruldra.superworkbench.settings.SettingsState) -> String): String = + pick(SettingsService.getInstance().state).trim() + + /** + * 解析脚本绝对路径:自定义覆盖非空时优先(相对则拼到 workspaceRoot),否则用默认相对路径。 + * 对齐源 `resolveScriptPath`。 + */ + private fun resolveScriptPath(ctx: WorktreeContext, customScriptPath: String, defaultRelPath: String): Path { + if (customScriptPath.isNotEmpty()) { + val custom = Paths.get(customScriptPath) + return if (custom.isAbsolute) custom else ctx.workspaceRoot.resolve(customScriptPath) + } + return ctx.workspaceRoot.resolve(defaultRelPath) + } + + /** + * 跑一个钩子脚本:`bash + + diff --git a/vscode/webview-ui/package.json b/vscode/webview-ui/package.json new file mode 100644 index 0000000..ad9e57c --- /dev/null +++ b/vscode/webview-ui/package.json @@ -0,0 +1,32 @@ +{ + "name": "webview-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite build --watch", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.16.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.3.0", + "typescript": "^5.9.3", + "vite": "^6.0.0" + } +} diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx new file mode 100644 index 0000000..432790d --- /dev/null +++ b/vscode/webview-ui/src/App.tsx @@ -0,0 +1,464 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { BottomTabs } from './components/BottomTabs' +import { KanbanBoard } from './components/KanbanBoard' +import { LogModal } from './components/LogModal' +import type { PastedImage } from './components/NewIssueModal' +import { NewIssueModal } from './components/NewIssueModal' +import { PanelHeader } from './components/PanelHeader' +import { SettingsModal } from './components/SettingsModal' +import { ToastStack } from './components/ToastStack' +import { useIssues } from './hooks/useIssues' +import { useManagedSessions } from './hooks/useManagedSessions' +import { useProfiles } from './hooks/useProfiles' +import { compareIssuesInColumn } from './lib/issueSort' +import type { Issue } from './types' +import { COLUMN_ORDER } from './types' + +export function App() { + const { + state, + settings, + globalAutoReview, + youtrackConfigured, + importYouTrack, + toasts, + profiles, + setIssues, + refresh, + saveSettings, + listYouTrackProjects, + youtrackProjects, + dismissSettings, + requestEditAuth, + createIssue, + dismissToast, + openUrl, + resumeSession, + resumeReviewSession, + startTestSession, + resumeTestSession, + focusSession, + openFile, + implement, + generatePrDiffSummary, + isPrDiffSummaryRunning, + openPr, + openWorktree, + deleteWorktree, + mergeBranch, + deleteIssue, + closeIssue, + closeSessionTab, + startBrainstormSession, + changeColumn, + setDependency, + clearDependency, + updateIssueAutoReview, + updateIssueProfilePath, + updateIssueTestProfilePath, + logs, + clearLogs, + pendingSelectId, + clearPendingSelect, + commitRunning, + runCommit, + hasChanges, + branchSyncBehind, + branchSyncRunning, + branchSyncDisabled, + branchSyncTitle, + runBranchSync, + envLocked, + envFileCount, + envLockRunning, + envLockTitle, + toggleEnvLock, + } = useIssues() + const { profiles: profileData, saveProfiles, openProfileValue } = useProfiles() + const { + managedSessions, + createManagedSession, + renameManagedSession, + resumeManagedSession, + deleteManagedSession, + closeManagedSessionTab, + } = useManagedSessions() + const [showNewIssueModal, setShowNewIssueModal] = useState(false) + const [showLogs, setShowLogs] = useState(false) + const [selectedId, setSelectedId] = useState(null) + // 程序化设置的选中(反向选中 / pendingSelectId)记在这里,让下面的自动聚焦 + // effect 跳过它——否则 选中→聚焦→终端激活→反向选中 会死循环、CPU 飙升。 + const lastProgrammaticSelectRef = useRef(null) + + function handleSubmitNewIssue(userRequest: string, images: PastedImage[], profilePath?: string): void { + const payload = images.map(({ mediaType, base64 }) => ({ mediaType, base64 })) + createIssue(userRequest, payload.length > 0 ? payload : undefined, profilePath) + setShowNewIssueModal(false) + } + + // Drop selectedId if it's no longer present after issues refresh. + const readyIssues = state.status === 'ready' ? state.issues : null + useEffect(() => { + if (!readyIssues) + return + if (selectedId && !readyIssues.some(i => i.id === selectedId)) + setSelectedId(null) + }, [readyIssues, selectedId]) + + // Auto-select issues that arrive via webhook in response to user-initiated + // creation (issue/append with select: true). User-initiated, so we + // intentionally override any current arrow-key selection. + useEffect(() => { + if (!pendingSelectId) + return + if (!readyIssues) + return + if (!readyIssues.some(i => i.id === pendingSelectId)) + return + lastProgrammaticSelectRef.current = pendingSelectId + setSelectedId(pendingSelectId) + clearPendingSelect() + }, [pendingSelectId, readyIssues, clearPendingSelect]) + + // Issues partitioned by column, in COLUMN_ORDER. Used for arrow navigation. + // 排序与看板视觉一致:完成列按 PR 合并时间降序,其余列按工单号降序。 + const ordered = useMemo(() => { + if (!readyIssues) + return [] + return COLUMN_ORDER.map(col => + readyIssues.filter(i => i.column === col).sort((a, b) => compareIssuesInColumn(col, a, b)), + ) + }, [readyIssues]) + + // Resolved selected issue (or null) for the detail panel. + const selectedIssue = useMemo(() => { + if (!readyIssues || !selectedId) + return null + return readyIssues.find(i => i.id === selectedId) ?? null + }, [readyIssues, selectedId]) + + // Auto-focus the terminal tab whenever the selected card has a session. + // 优先 implementSessionId(实施终端),没有再回退到 sessionId(规划/头脑风暴终端); + // 依赖里加入 implementSessionId,让"先有规划、后有实施"的场景能正确切换。 + useEffect(() => { + if (!selectedIssue) + return + // 这次选中是程序化设置的(反向选中等)→ 不聚焦终端,打断死循环。仅用户主动 + // 点卡片/方向键切换才走聚焦。按 id 匹配,避免同一工单反向选中后残留的标记 + // 误压制后续的用户选中。 + if (selectedIssue.id === lastProgrammaticSelectRef.current) { + lastProgrammaticSelectRef.current = null + return + } + if (selectedIssue.implementSessionId || selectedIssue.sessionId) + focusSession(selectedIssue.number) + }, [selectedIssue?.implementSessionId, selectedIssue?.sessionId, selectedIssue?.number, focusSession]) + + // Global keyboard navigation. Skipped whenever an overlay (new-issue, logs, + // settings) is showing, so its inputs receive arrow/Enter without + // interference. + const settingsOpen = settings !== null + useEffect(() => { + if (state.status !== 'ready') + return + if (showNewIssueModal) + return + if (showLogs) + return + if (settingsOpen) + return + + function onKeyDown(e: KeyboardEvent): void { + // Skip when typing in form fields. + const active = document.activeElement + const tag = active?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') + return + // Editable elements (e.g. contenteditable divs). + if (active && (active as HTMLElement).isContentEditable) + return + // 焦点在提交列表(role=listbox)等自管键盘的区域时,让它自己处理方向键。 + if (active && (active as HTMLElement).closest?.('[role="listbox"]')) + return + + if (e.key === 'Escape') { + if (selectedId !== null) { + e.preventDefault() + setSelectedId(null) + } + return + } + + if (e.key === 'Enter') { + const sid = selectedIssue?.implementSessionId || selectedIssue?.sessionId + if (sid) { + e.preventDefault() + const isImpl = !!selectedIssue?.implementSessionId + const cwd = isImpl ? selectedIssue?.worktreePath : undefined + resumeSession(sid, selectedIssue?.profilePath, cwd, selectedIssue?.number) + } + return + } + + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') + return + + // Locate current selection within `ordered`. + let colIdx = -1 + let rowIdx = -1 + if (selectedId) { + for (let c = 0; c < ordered.length; c++) { + const r = ordered[c].findIndex(i => i.id === selectedId) + if (r >= 0) { + colIdx = c + rowIdx = r + break + } + } + } + + // No current selection: pick the first card in the first non-empty + // column (typically todo[0]). + if (colIdx < 0) { + for (let c = 0; c < ordered.length; c++) { + if (ordered[c].length > 0) { + e.preventDefault() + setSelectedId(ordered[c][0].id) + return + } + } + return + } + + if (e.key === 'ArrowUp') { + if (rowIdx > 0) { + e.preventDefault() + setSelectedId(ordered[colIdx][rowIdx - 1].id) + } + return + } + + if (e.key === 'ArrowDown') { + if (rowIdx < ordered[colIdx].length - 1) { + e.preventDefault() + setSelectedId(ordered[colIdx][rowIdx + 1].id) + } + return + } + + // Horizontal navigation: keep rowIdx, clamp to target column length - 1. + // Skip empty columns entirely. + const dir = e.key === 'ArrowLeft' ? -1 : 1 + let target = colIdx + dir + while (target >= 0 && target < ordered.length && ordered[target].length === 0) + target += dir + if (target < 0 || target >= ordered.length) + return + e.preventDefault() + const clampedRow = Math.min(rowIdx, ordered[target].length - 1) + setSelectedId(ordered[target][clampedRow].id) + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [state.status, showNewIssueModal, showLogs, settingsOpen, ordered, selectedId, selectedIssue, resumeSession]) + + // When the user is being forced into first-time setup, the kanban behind + // the modal has nothing meaningful to show — replace the spinner with a + // muted hint so it doesn't look like things are still loading. + const blockedBySetup = settings !== null && !settings.canCancel && state.status === 'loading' + + return ( +
+ {state.status === 'loading' && ( + <> + +
+ {blockedBySetup ? '请先完成设置' : '加载中…'} +
+ + )} + + {state.status === 'ready' && ( + <> + +
+
+ setShowNewIssueModal(true)} + selectedId={selectedId} + onSelectIssue={setSelectedId} + onColumnChange={changeColumn} + onDependencySet={setDependency} + onDependencyClear={clearDependency} + /> +
+
+ setShowLogs(true)} + profileData={profileData} + onProfileSave={saveProfiles} + onProfileOpen={openProfileValue} + managedSessions={managedSessions} + profiles={profiles} + onManagedSessionCreate={createManagedSession} + onManagedSessionRename={renameManagedSession} + onManagedSessionResume={resumeManagedSession} + onManagedSessionDelete={deleteManagedSession} + onManagedSessionCloseTab={closeManagedSessionTab} + /> +
+
+ + )} + + {state.status === 'error' && ( + <> + +
+
+
无法加载 issues
+
{state.message}
+
+ + +
+
+
+ + )} + + setShowNewIssueModal(false)} + onSubmit={handleSubmitNewIssue} + profiles={profiles} + defaultProfileName="offical" + /> + setShowLogs(false)} + onClear={clearLogs} + /> + + +
+ ) +} diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx new file mode 100644 index 0000000..f94db50 --- /dev/null +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -0,0 +1,181 @@ +/** + * 底部 panel:左侧垂直 tab 切换两个 property grid。 + * + * - tab 1 「工单」:现有 IssueDetailPanel(基于 PropertyGrid) + * - tab 2 「Profile」:工作区 KV 表 ProfileGrid + * + * tab 状态用 useState,无持久化;激活态加左侧 2px 高亮条。 + */ + +import type { ClaudeProfile } from '../hooks/useIssues' +import type { ProfilesData } from '../lib/messages' +import type { Issue, ManagedSessionsData } from '../types' +import { ClipboardList, GitCommitHorizontal, Layers, MessagesSquare } from 'lucide-react' +import { useState } from 'react' +import { IssueDetailPanel } from './IssueDetailPanel' +import { ManagedSessionsPanel } from './ManagedSessionsPanel' +import { PrCommitsPanel } from './PrCommitsPanel' +import { ProfileGrid } from './ProfileGrid' + +type TabKey = 'issue' | 'profile' | 'sessions' | 'commits' + +interface BottomTabsProps { + // Issue tab props (pass-through to IssueDetailPanel) + issue: Issue | null + allIssues: Issue[] + globalAutoReview: boolean + onOpenInBrowser: (url: string) => void + onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void + onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void + onStartTestSession: (issueNumber: number) => void + onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void + onOpenFile: (path: string) => void + onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void + onOpenPr: (pr: string) => void + onGeneratePrDiffSummary: (issueNumber: number) => void + isPrDiffSummaryRunning: (issueNumber: number) => boolean + onOpenWorktree: (path: string) => void + onDeleteWorktree: (issueNumber: number, path: string) => void + onMergeBranch: (issueNumber: number, branch: string) => void + onDeleteIssue?: (issueNumber: number) => void + onCloseIssue: (issueNumber: number) => void + onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void + onStartBrainstormSession: (issueNumber: number) => void + onUpdateAutoReview: (issueNumber: number, value: boolean) => void + onUpdateProfilePath: (issueNumber: number, profilePath: string) => void + onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void + onOpenLogs: () => void + + // Profile tab props + profileData: ProfilesData + onProfileSave: (next: ProfilesData) => void + onProfileOpen: (value: string) => void + + // 会话管理 tab props + managedSessions: ManagedSessionsData + profiles: ClaudeProfile[] + onManagedSessionCreate: (profilePath: string, name?: string, prompt?: string) => void + onManagedSessionRename: (id: string, name: string) => void + onManagedSessionResume: (id: string) => void + onManagedSessionDelete: (id: string) => void + onManagedSessionCloseTab: (id: string) => void +} + +export function BottomTabs(props: BottomTabsProps) { + const [tab, setTab] = useState('issue') + + return ( +
+ {/* 左侧垂直 tab 栏 */} +
+ setTab('issue')} + icon={} + title="工单" + /> + setTab('profile')} + icon={} + title="Profile" + /> + setTab('sessions')} + icon={} + title="会话" + /> + setTab('commits')} + icon={} + title="提交" + /> +
+ + {/* 右侧内容 */} +
+ {tab === 'issue' && ( + + )} + {tab === 'profile' && ( + + )} + {tab === 'sessions' && ( + + )} + {tab === 'commits' && ( + + )} +
+
+ ) +} + +interface TabButtonProps { + active: boolean + onClick: () => void + icon: React.ReactNode + title: string +} + +function TabButton({ active, onClick, icon, title }: TabButtonProps) { + return ( + + ) +} diff --git a/vscode/webview-ui/src/components/IssueCard.tsx b/vscode/webview-ui/src/components/IssueCard.tsx new file mode 100644 index 0000000..a69f176 --- /dev/null +++ b/vscode/webview-ui/src/components/IssueCard.tsx @@ -0,0 +1,82 @@ +import type { Ref } from 'react' +import { forwardRef } from 'react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Lock } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Card } from './ui/card' +import type { Issue } from '../types' + +interface IssueCardProps { + issue: Issue + selected?: boolean + onSelect?: (id: string) => void + /** Visual nesting level for prerequisite-chain rendering in the todo column. + * Each level adds 16px of left margin. Defaults to 0 (flat). */ + depth?: number + /** 锁定态:前置工单尚未完成。显示锁图标 + tooltip。 */ + locked?: boolean + /** 锁定时关联的前置工单号,用于 tooltip 文案 "等待 #N 完成"。 */ + lockedPrerequisite?: number +} + +export const IssueCard = forwardRef( + ({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite }, ref) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } + = useSortable({ id: issue.id, data: { column: issue.column } }) + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + marginLeft: depth * 16, + } + + // Merge dnd-kit's ref with the forwarded ref so the parent can scrollIntoView. + function setRefs(node: HTMLDivElement | null): void { + setNodeRef(node) + if (typeof ref === 'function') + ref(node) + else if (ref) + (ref as React.MutableRefObject).current = node + } + + return ( + } + style={style} + {...attributes} + {...listeners} + onClick={() => onSelect?.(issue.id)} + className={cn( + 'relative select-none px-3 py-2 text-xs', + 'cursor-grab active:cursor-grabbing', + locked && 'opacity-75', + selected + && 'ring-2 ring-[var(--vscode-focusBorder)] ring-offset-1 ring-offset-[var(--vscode-sideBar-background)]', + )} + > + {locked + ? ( + + + + ) + : null} +
+ + {issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`} + + {' '} + {issue.title} +
+
+ ) + }, +) + +IssueCard.displayName = 'IssueCard' diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx new file mode 100644 index 0000000..be68f41 --- /dev/null +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -0,0 +1,594 @@ +import type { ReactNode } from 'react' +import { useMemo, useRef } from 'react' +import { CircleSlash, ExternalLink, GitMerge, Play, Terminal, Trash2, X } from 'lucide-react' +import type { ClaudeProfile } from '../hooks/useIssues' +import type { Issue, IssueColumn } from '../types' +import { COLUMN_LABELS, COLUMN_ORDER } from '../types' +import { isIssueLocked } from '../lib/dependencies' +import type { PropertyGroup } from './property-grid' +import { PropertyGrid } from './property-grid' + +interface IssueDetailPanelProps { + issue: Issue | null + /** 当前所有 issues,用于解析前置依赖锁定态。 */ + allIssues: Issue[] + /** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */ + globalAutoReview: boolean + onOpenInBrowser: (url: string) => void + onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void + /** Open a new terminal that runs `codex resume ` for the auto-review + * conversation associated with this issue. */ + onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void + /** Open a new terminal that runs `claude --resume ` for the test + * conversation associated with this issue. */ + onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void + /** Spawn a fresh "测试" cc tab for an issue whose PR has merged. Bound to the + * Play button next to the "测试会话 id" row (only shown when `issue.pr` exists). */ + onStartTestSession: (issueNumber: number) => void + /** Open a workspace-relative file in the editor. */ + onOpenFile: (path: string) => void + /** Kick off the end-to-end implementation flow for the given plan file. */ + onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void + /** Open the gitea PR page in the browser. */ + onOpenPr: (pr: string) => void + /** Start a background Claude run that writes the PR diff summary markdown. */ + onGeneratePrDiffSummary: (issueNumber: number) => void + /** Whether a PR diff summary generation request is in flight for an issue. */ + isPrDiffSummaryRunning: (issueNumber: number) => boolean + /** Open the workspace-relative worktree path in a new VS Code window. */ + onOpenWorktree: (path: string) => void + /** Delete the worktree (`git worktree remove`) and clear it from state. */ + onDeleteWorktree: (issueNumber: number, path: string) => void + /** Pre-merge the issue's feature branch into the main worktree's current branch + * (`git merge --no-commit --no-ff `) so the user can inspect locally. */ + onMergeBranch: (issueNumber: number, branch: string) => void + /** 硬删整个工单 + 关联资源(worktree / PR / feature branch / cc tabs)。 + * 顶部垃圾桶按钮触发,扩展端做 modal confirm,可选。 */ + onDeleteIssue?: (issueNumber: number) => void + /** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */ + onCloseIssue: (issueNumber: number) => void + /** Close the matching session terminal tab. The extension watches + * onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which + * makes the X button vanish on its own. */ + onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void + /** Spawn a fresh "规划" cc tab for an issue whose sessionId is still empty + * (created out-of-band via spx without webhook linking). Bound to the Play + * button next to the "头脑风暴会话 id" row. */ + onStartBrainstormSession: (issueNumber: number) => void + /** Persist a per-issue `autoReview` override into the state JSON. */ + onUpdateAutoReview: (issueNumber: number, value: boolean) => void + /** 已知 Claude profile 列表,用于「配置文件」下拉选项。 */ + profiles: ClaudeProfile[] + /** Persist a per-issue `profilePath` override into the state JSON. */ + onUpdateProfilePath: (issueNumber: number, profilePath: string) => void + /** Persist a per-issue `testProfilePath` override into the state JSON. */ + onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void + /** Open the in-webview log modal. */ + onOpenLogs: () => void +} + +/** + * 底部详情面板:以 PropertyGrid 形式展示当前选中 issue 的 state JSON。 + * + * 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮;下面是 PropertyGrid + * 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是 + * 一个 action 类型字段,点击或选中卡按 Enter 都会触发 + * `claude --resume ` 在新终端中恢复对话。 + */ +export function IssueDetailPanel({ + issue, + allIssues, + globalAutoReview, + onOpenInBrowser, + onResumeSession, + onResumeReviewSession, + onResumeTestSession, + onStartTestSession, + onOpenFile, + onImplement, + onOpenPr, + onGeneratePrDiffSummary, + isPrDiffSummaryRunning, + onOpenWorktree, + onDeleteWorktree, + onMergeBranch, + onDeleteIssue, + onCloseIssue, + onCloseSessionTab, + onStartBrainstormSession, + onUpdateAutoReview, + profiles, + onUpdateProfilePath, + onUpdateTestProfilePath, + onOpenLogs, +}: IssueDetailPanelProps) { + // 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮, + // 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。 + const { locked, prerequisiteNumber } = issue + ? isIssueLocked(issue, allIssues) + : { locked: false, prerequisiteNumber: undefined as number | undefined } + const lockTitle = locked && prerequisiteNumber != null + ? `等待 #${prerequisiteNumber} 完成` + : undefined + const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false + + // 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表, + // 防止用户连续快速点击同一行时触发多次 resume —— extension 端 findExistingTerminal + // 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。 + // key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。 + const lastFiredAtRef = useRef>(new Map()) + const RESUME_THROTTLE_MS = 800 + const throttleBySessionId = (sid: string): boolean => { + const now = Date.now() + const last = lastFiredAtRef.current.get(sid) ?? 0 + if (now - last < RESUME_THROTTLE_MS) + return false + lastFiredAtRef.current.set(sid, now) + return true + } + + const schema = useMemo( + () => [ + { + id: 'state', + label: 'state JSON', + actions: ( + + ), + properties: [ + { + key: 'column', + label: '状态', + type: 'select', + readOnly: true, + description: '工单所在的看板列;写入 issue 最后一条 JSON 评论的 column 字段', + options: COLUMN_ORDER.map((c: IssueColumn) => ({ + label: `${c}(${COLUMN_LABELS[c]})`, + value: c, + })), + }, + (() => { + // 头脑风暴会话 id 行有三种状态: + // 1. sessionId 已存在 + brainstormTabOpen=true → 显示 id + X 关闭 tab + // 2. sessionId 已存在 + brainstormTabOpen=false → 显示 id(无右侧按钮) + // 3. sessionId 为空 + column !== 'done' → 显示 Play 按钮(启动一个新规划 cc tab) + // 4. sessionId 为空 + column === 'done' → 占位 —,不显示按钮 + const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0) + const canStart = !hasSession && issue?.column !== 'done' + let secondaryActionIcon: ReactNode | undefined + let secondaryActionTitle: string | undefined + let onSecondaryAction: (() => void) | undefined + if (hasSession && issue?.brainstormTabOpen) { + secondaryActionIcon = + secondaryActionTitle = '关闭头脑风暴 tab' + onSecondaryAction = () => { + if (issue) + onCloseSessionTab(issue.number, 'brainstorm') + } + } + else if (canStart) { + secondaryActionIcon = + secondaryActionTitle = '启动头脑风暴会话' + onSecondaryAction = () => { + if (issue) + onStartBrainstormSession(issue.number) + } + } + return { + key: 'sessionId', + label: '头脑风暴会话id', + type: 'action' as const, + description: hasSession + ? '点击在新终端运行 claude --resume 恢复对话' + : '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab', + actionIcon: , + onAction: (v: unknown) => { + if (typeof v !== 'string' || v.length === 0) + return + if (!throttleBySessionId(v)) + return + onResumeSession(v, issue?.profilePath, undefined, issue?.number) + }, + secondaryActionIcon, + secondaryActionTitle, + onSecondaryAction, + } + })(), + // 实施会话即使 worktree 已被清掉也保留可点击的 resume 入口: + // extension 端 handleResumeSession 会在 worktree 路径不存在时退回 + // 工作区根目录,并通过 toast 告知用户;description 在两种状态下 + // 给出不同的提示文案。 + { + key: 'implementSessionId', + label: '实施会话id', + type: 'action', + description: issue?.worktreeExists + ? '点击在新终端运行 claude --resume 恢复实施对话(cwd 为 worktree)' + : 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)', + actionIcon: , + onAction: (v) => { + if (typeof v !== 'string' || v.length === 0) + return + if (!throttleBySessionId(v)) + return + onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number) + }, + secondaryActionIcon: issue?.implementTabOpen + ? + : undefined, + secondaryActionTitle: '关闭实施 tab', + onSecondaryAction: () => { + if (issue) + onCloseSessionTab(issue.number, 'implement') + }, + }, + issue?.worktreeExists + ? { + key: 'reviewSessionId', + label: '审查会话id', + type: 'action', + description: '点击在新终端运行 codex resume 查看审查会话(cwd 优先用 worktree)', + actionIcon: , + onAction: (v) => { + if (typeof v !== 'string' || v.length === 0 || !issue) + return + if (!throttleBySessionId(v)) + return + onResumeReviewSession(v, issue.number, issue?.worktreePath) + }, + secondaryActionIcon: issue?.reviewTabOpen + ? + : undefined, + secondaryActionTitle: '关闭审查 tab', + onSecondaryAction: () => { + if (issue) + onCloseSessionTab(issue.number, 'review') + }, + } + : { + key: 'reviewSessionId', + label: '审查会话id', + type: 'string', + readOnly: true, + description: 'worktree 已清理,无法 resume;仅保留 id 文本', + }, + (() => { + // 测试会话 id 行有三种状态(照搬头脑风暴行的三态): + // 1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab + // 2. testSessionId 已存在 + testTabOpen=false → 显示 id,点击 resume + // 3. testSessionId 为空 + issue.pr 存在 → 显示 Play 启动新测试 cc tab + // (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮) + const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0) + const canStart = !hasSession && !!issue?.pr + let secondaryActionIcon: ReactNode | undefined + let secondaryActionTitle: string | undefined + let onSecondaryAction: (() => void) | undefined + if (hasSession && issue?.testTabOpen) { + secondaryActionIcon = + secondaryActionTitle = '关闭测试 tab' + onSecondaryAction = () => { + if (issue) + onCloseSessionTab(issue.number, 'test') + } + } + else if (canStart) { + secondaryActionIcon = + secondaryActionTitle = '启动测试会话' + onSecondaryAction = () => { + if (issue) + onStartTestSession(issue.number) + } + } + return { + key: 'testSessionId', + label: '测试会话id', + type: 'action' as const, + description: hasSession + ? '点击在新终端运行 claude --resume 在工作区根(main)恢复测试对话' + : '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)', + actionIcon: , + onAction: (v: unknown) => { + if (typeof v !== 'string' || v.length === 0 || !issue) + return + if (!throttleBySessionId(v)) + return + onResumeTestSession(v, issue.number) + }, + secondaryActionIcon, + secondaryActionTitle, + onSecondaryAction, + } + })(), + { + key: 'autoReview', + label: '自动审查', + type: 'boolean', + description: `打开新 PR 时是否自动启动 codex 审查;未设置时跟随全局(当前全局:${globalAutoReview ? '开' : '关'})`, + }, + { + key: 'color', + label: '颜色', + type: 'theme-color', + readOnly: true, + description: '首次开会话时随机分配的终端颜色(VS Code ThemeColor),用于该工单所有会话的终端 tab 着色', + }, + { + key: 'profilePath', + label: '实施配置文件', + type: 'select', + options: (() => { + // 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何 + // option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发 + // onChange、永远存不进去;占位项让未设置态有真实匹配项。 + const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))] + const current = issue?.profilePath + // 自定义路径兜底:state JSON 里存的 profile 不在已知列表时, + // 额外追加一项以免下拉显示空白、丢失当前选择。 + if (current && !opts.some(o => o.value === current)) + opts.push({ label: `自定义:${current}`, value: current }) + return opts + })(), + description: '实施会话使用的 Claude 配置文件;选「(默认)」用内置默认(offical),选具体 profile 则覆盖(持久化 state JSON)', + }, + { + key: 'testProfilePath', + label: '测试配置文件', + type: 'select', + options: (() => { + // 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何 + // option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发 + // onChange、永远存不进去;占位项让未设置态有真实匹配项。 + const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))] + const current = issue?.testProfilePath + // 自定义路径兜底:state JSON 里存的 profile 不在已知列表时, + // 额外追加一项以免下拉显示空白、丢失当前选择。 + if (current && !opts.some(o => o.value === current)) + opts.push({ label: `自定义:${current}`, value: current }) + return opts + })(), + description: '测试会话使用的 Claude 配置文件;留空时回退实施配置文件', + }, + { + key: 'specFile', + label: '规格文件', + type: 'file-link', + description: '点击文件名在编辑器打开;cc 通过 issue body 的 注释实时同步', + onOpen: (p: string) => onOpenFile(p), + }, + { + key: 'planFile', + label: '计划文件', + type: 'file-link', + description: '点击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 注释实时同步', + onOpen: (p: string) => onOpenFile(p), + // Hide the "实施" button entirely when there's no plan file. + secondaryActionIcon: issue?.planFile + ? + : undefined, + secondaryActionTitle: locked ? lockTitle : '实施此计划', + // Disable while running or already done. The user can still click + // when status === 'failed' or never been run. Re-clicking while + // 'running' is blocked at the UI to avoid registering duplicate + // webhooks. To retry after a perceived failure with status still + // 'running', edit the state-JSON comment manually for now. + // 锁定态(前置工单未完成)同样禁止启动实施。 + secondaryDisabled: + !issue?.planFile + || issue?.implementStatus === 'running' + || issue?.implementStatus === 'done' + || locked, + onSecondaryAction: () => { + if (!issue?.planFile) + return + onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId) + }, + }, + { + key: 'pr', + label: '合并请求', + type: 'pr-link', + description: '点击在浏览器打开关联的 PR;点击右侧按钮生成 PR 变更摘要;已合并时编号后追加 "(已合并)" 标识', + onAction: (v) => { + if (typeof v !== 'string' || v.length === 0) + return + // value 形如 "65" 或 "65(已合并)";剥掉合并后缀只把纯编号传出去。 + const pr = v.replace(/\(已合并\)\s*$/, '') + if (pr.length > 0) + onOpenPr(pr) + }, + secondaryActionIcon: issue?.pr && !issue?.prDiffFile + ? + : undefined, + secondaryActionTitle: prDiffSummaryRunning + ? 'PR 变更摘要生成中' + : '生成 PR 变更摘要', + secondaryDisabled: prDiffSummaryRunning, + onSecondaryAction: () => { + if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning) + onGeneratePrDiffSummary(issue.number) + }, + }, + { + key: 'prDiffFile', + label: 'PR 变更摘要', + type: 'file-link', + description: '点击文件名在编辑器打开生成的 PR 代码变更摘要', + onOpen: (p: string) => onOpenFile(p), + }, + { + key: 'branch', + label: '分支', + type: 'string', + readOnly: true, + description: '实施流程创建的分支名', + secondaryActionIcon: issue?.branch ? : undefined, + secondaryActionTitle: '本地预合并 (git merge --no-commit --no-ff)', + onSecondaryAction: () => { + if (issue?.branch) + onMergeBranch(issue.number, issue.branch) + }, + }, + issue?.worktreeExists + ? { + key: 'worktreePath', + label: '工作树', + type: 'file-link', + description: '点击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree', + onOpen: (p: string) => onOpenWorktree(p), + onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''), + secondaryActionIcon: , + secondaryActionTitle: '删除 worktree', + } + : { + key: 'worktreePath', + label: '工作树', + type: 'string', + readOnly: true, + description: '实施流程创建的 git worktree 路径(workspace 相对)', + }, + ], + }, + ], + [onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onStartBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles], + ) + + if (!issue) { + return ( +
+ 按方向键或点击卡片查看详情 +
+ ) + } + + const data: Record = { + column: issue.column, + sessionId: issue.sessionId ?? null, + implementSessionId: issue.implementSessionId ?? null, + reviewSessionId: issue.reviewSessionId ?? null, + testSessionId: issue.testSessionId ?? null, + autoReview: issue.autoReview ?? globalAutoReview, + color: issue.color ?? null, + profilePath: issue.profilePath ?? null, + testProfilePath: issue.testProfilePath ?? null, + specFile: issue.specFile ?? null, + planFile: issue.planFile ?? null, + prDiffFile: issue.prDiffFile ?? null, + pr: issue.pr + ? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}` + : null, + branch: issue.branch ?? null, + worktreePath: issue.worktreePath ?? null, + } + + return ( +
+
+

+ + {issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`} + + {issue.source === 'youtrack' && ( + + YouTrack + + )} + {' '} + {issue.title} + {locked && prerequisiteNumber != null + ? ( + + 等待 # + {prerequisiteNumber} + {' '} + 完成 + + ) + : null} +

+ + {/* Close/delete write to gitea by number — hide for youtrack cards to + avoid acting on a gitea issue of the same number. */} + {issue.source !== 'youtrack' && ( + <> + + + + )} +
+ + {issue.source === 'youtrack' && issue.attachments && issue.attachments.length > 0 && ( +
+ 附件({issue.attachments.length}) +
+ {issue.attachments.map(att => ( + + ))} +
+
+ )} + +
+ { + if (key === 'autoReview' && issue && typeof value === 'boolean') + onUpdateAutoReview(issue.number, value) + if (key === 'profilePath' && issue && typeof value === 'string') + onUpdateProfilePath(issue.number, value) + if (key === 'testProfilePath' && issue && typeof value === 'string') + onUpdateTestProfilePath(issue.number, value) + }} + /> +
+
+ ) +} diff --git a/vscode/webview-ui/src/components/KanbanBoard.tsx b/vscode/webview-ui/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..7f5d6cc --- /dev/null +++ b/vscode/webview-ui/src/components/KanbanBoard.tsx @@ -0,0 +1,377 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { IssueCard } from './IssueCard' +import { KanbanColumn } from './KanbanColumn' +import { isIssueLocked } from '../lib/dependencies' +import { compareIssuesInColumn } from '../lib/issueSort' +import type { Issue, IssueColumn } from '../types' +import { COLUMN_ORDER } from '../types' + +interface KanbanBoardProps { + issues: Issue[] + onIssuesChange: (next: Issue[]) => void + /** Renders the + button on the todo column when provided. */ + onCreateIssue?: () => void + selectedId?: string | null + onSelectIssue?: (id: string | null) => void + /** + * Called when a card moves to a new column via drag-and-drop, in addition + * to the optimistic `onIssuesChange`. Currently only invoked for moves + * targeting `done`; other column changes remain client-visual-only. + */ + onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void + /** + * Called when a drag gesture establishes a new prerequisite relationship + * (drop onto another todo card's middle 1/3) or moves to a sibling whose + * parent differs from the active card's current parent. + */ + onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void + /** + * Called when a drag gesture clears an existing prerequisite (e.g. drop + * onto the todo column's empty area, or onto a sibling at root level). + */ + onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void +} + +function isColumnId(id: string): id is IssueColumn { + return (COLUMN_ORDER as string[]).includes(id) +} + +/** + * Organize todo-column issues into a forest ordered by prerequisite chains. + * + * Roots are issues whose `prerequisite` is empty or points outside the todo + * column (cross-column pointers don't form parent/child here — they only + * matter for locking in a later step). Children are issues whose + * `prerequisite` points to another issue in the same todo column. + * + * Order: pre-order DFS. Root relative order follows the input array; + * sibling order under each parent also follows input order. A visited Set + * guards against pathological cycles (one issue can only have one parent, + * so this is a forest in normal cases). + */ +function buildTodoTree(todoIssues: Issue[]): Array<{ issue: Issue, depth: number }> { + const byNumber = new Map() + for (const issue of todoIssues) + byNumber.set(issue.number, issue) + + // Group children by parent number, preserving input order. + const childrenOf = new Map() + const roots: Issue[] = [] + for (const issue of todoIssues) { + const parentNum = issue.prerequisite + if (parentNum != null && byNumber.has(parentNum)) { + const arr = childrenOf.get(parentNum) ?? [] + arr.push(issue) + childrenOf.set(parentNum, arr) + } + else { + roots.push(issue) + } + } + + const out: Array<{ issue: Issue, depth: number }> = [] + const visited = new Set() + function visit(issue: Issue, depth: number): void { + if (visited.has(issue.id)) + return + visited.add(issue.id) + out.push({ issue, depth }) + const kids = childrenOf.get(issue.number) + if (!kids) + return + for (const child of kids) + visit(child, depth + 1) + } + for (const root of roots) + visit(root, 0) + return out +} + +export function KanbanBoard({ + issues, + onIssuesChange, + onCreateIssue, + selectedId, + onSelectIssue, + onColumnChange, + onDependencySet, + onDependencyClear, +}: KanbanBoardProps) { + const [activeId, setActiveId] = useState(null) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + const issuesByColumn = useMemo(() => { + const map: Record = { + 'todo': [], + 'in-progress': [], + 'review': [], + 'done': [], + } + for (const issue of issues) + map[issue.column].push(issue) + // 各列排序:完成列按 PR 合并时间降序(最近合并在最前),其余列按 issue.number + // 降序让新工单置顶。todo 列的降序顺序同时是 `buildTodoTree` 的输入顺序——它按 + // 输入顺序 push 根节点,所以可见根节点也跟着 number 降序。 + for (const col of Object.keys(map) as IssueColumn[]) + map[col].sort((a, b) => compareIssuesInColumn(col, a, b)) + return map + }, [issues]) + + const activeIssue = activeId ? issues.find(i => i.id === activeId) ?? null : null + + // Map of issue id -> DOM node, used so keyboard navigation can scrollIntoView. + const cardRefs = useRef(new Map()) + + useEffect(() => { + if (!selectedId) + return + const node = cardRefs.current.get(selectedId) + node?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }, [selectedId]) + + function handleDragStart(event: DragStartEvent): void { + setActiveId(String(event.active.id)) + } + + /** + * Classify the drop position relative to the `over` card. Middle 1/3 + * means nest (set prerequisite); the upper/lower 2/3 means sibling reorder. + * The 1/3-2/3 split is intentionally wider than 1/4-1/2-1/4 because the + * sortable strategy reflows during drag, so the user needs more room. + */ + function detectDropZone(event: DragEndEvent): 'middle' | 'edge' { + const activeRect = event.active.rect.current.translated + const overRect = event.over?.rect + if (!activeRect || !overRect) + return 'edge' + const activeCenterY = (activeRect.top + activeRect.bottom) / 2 + const overHeight = overRect.height + // Widen the nest zone to the middle 60% of the over card so user doesn't + // need pixel-precision. Edge zone is just the top/bottom 20% slivers, + // which are still enough for sibling-reorder gestures. + const middleStart = overRect.top + overHeight * 0.2 + const middleEnd = overRect.top + overHeight * 0.8 + return activeCenterY >= middleStart && activeCenterY <= middleEnd ? 'middle' : 'edge' + } + + /** + * Walks up the prerequisite chain from `target`, returning true if + * `maybeAncestor` appears anywhere on the way. Uses a visited set so a + * pre-existing cycle in the data doesn't spin forever. + */ + function isDescendant(maybeAncestor: number, target: number, all: Issue[]): boolean { + const visited = new Set() + let current: Issue | undefined = all.find(i => i.number === target) + while (current && current.prerequisite != null) { + if (visited.has(current.number)) + return false + visited.add(current.number) + if (current.prerequisite === maybeAncestor) + return true + current = all.find(i => i.number === current!.prerequisite) + } + return false + } + + function handleDragEnd(event: DragEndEvent): void { + const { active, over } = event + setActiveId(null) + if (!over) + return + + const activeIdStr = String(active.id) + const overIdStr = String(over.id) + if (activeIdStr === overIdStr) + return + + const activeIndex = issues.findIndex(i => i.id === activeIdStr) + if (activeIndex < 0) + return + const activeItem = issues[activeIndex] + + // Lock check: a todo card whose prerequisite is still unfinished must + // stay in the todo column. Resolve target column from either the over + // column id or the over card's column. + const prereqIssue = activeItem.prerequisite != null + ? issues.find(i => i.number === activeItem.prerequisite) + : undefined + const isLocked = !!prereqIssue && prereqIssue.column !== 'done' + if (isLocked && activeItem.column === 'todo') { + const overItemForLock = isColumnId(overIdStr) + ? undefined + : issues.find(i => i.id === overIdStr) + const targetColumn: IssueColumn | undefined = isColumnId(overIdStr) + ? overIdStr + : overItemForLock?.column + if (targetColumn && targetColumn !== 'todo') + return + } + + // Dropped onto a column container + if (isColumnId(overIdStr)) { + // Drop onto the todo column's empty area while the card is already in + // todo → clear prerequisite (move to root level, append at column end). + if (overIdStr === 'todo' && activeItem.column === 'todo' && activeItem.prerequisite != null) { + const prevPrereq = activeItem.prerequisite + const next = [...issues] + next.splice(activeIndex, 1) + next.push({ ...activeItem, prerequisite: undefined }) + onIssuesChange(next) + onDependencyClear?.(activeItem.number, prevPrereq) + return + } + if (activeItem.column === overIdStr) + return + const next = [...issues] + next.splice(activeIndex, 1) + next.push({ ...activeItem, column: overIdStr }) + onIssuesChange(next) + onColumnChange?.(activeItem.number, overIdStr, activeItem.source, activeItem.externalId) + return + } + + // Dropped onto another card + const overIndex = issues.findIndex(i => i.id === overIdStr) + if (overIndex < 0) + return + const overItem = issues[overIndex] + + // Cross-column or non-todo-to-non-todo: keep the existing semantics. + if (activeItem.column !== 'todo' || overItem.column !== 'todo') { + if (activeItem.column === overItem.column) { + onIssuesChange(arrayMove(issues, activeIndex, overIndex)) + return + } + // Cross-column: drop activeItem at overItem's position in the new column + const next = [...issues] + next.splice(activeIndex, 1) + const newOverIndex = next.findIndex(i => i.id === overIdStr) + const insertAt = newOverIndex < 0 ? next.length : newOverIndex + next.splice(insertAt, 0, { ...activeItem, column: overItem.column }) + onIssuesChange(next) + onColumnChange?.(activeItem.number, overItem.column, activeItem.source, activeItem.externalId) + return + } + + // Both in todo: distinguish nest (middle 1/3) vs sibling reorder (edge). + const zone = detectDropZone(event) + if (zone === 'middle') { + // Don't allow nesting onto our own descendant (would form a cycle). + if (isDescendant(activeItem.number, overItem.number, issues)) + return + // Already nested under this same parent → nothing to do. + if (activeItem.prerequisite === overItem.number) + return + const next = [...issues] + next.splice(activeIndex, 1) + const newOverIndex = next.findIndex(i => i.id === overIdStr) + const insertAt = newOverIndex < 0 ? next.length : newOverIndex + 1 + next.splice(insertAt, 0, { ...activeItem, prerequisite: overItem.number }) + onIssuesChange(next) + onDependencySet?.(activeItem.number, overItem.number) + return + } + + // edge: sibling reorder. Inherit the over card's prerequisite as parent. + const newParent = overItem.prerequisite + if (newParent === activeItem.prerequisite) { + // Same parent (including both undefined): pure reorder. + onIssuesChange(arrayMove(issues, activeIndex, overIndex)) + return + } + // Cycle guard: refuse to set the new parent if it would put active under + // its own descendant. Still reorder visually. + const cycle = newParent != null && isDescendant(activeItem.number, newParent, issues) + if (cycle) { + onIssuesChange(arrayMove(issues, activeIndex, overIndex)) + return + } + const next = [...issues] + next.splice(activeIndex, 1) + const newOverIndex = next.findIndex(i => i.id === overIdStr) + const insertAt = newOverIndex < 0 ? next.length : newOverIndex + next.splice(insertAt, 0, { ...activeItem, prerequisite: newParent }) + onIssuesChange(next) + if (newParent == null) + onDependencyClear?.(activeItem.number, activeItem.prerequisite!) + else + onDependencySet?.(activeItem.number, newParent) + } + + return ( + setActiveId(null)} + > +
+ {COLUMN_ORDER.map((column) => { + const columnIssues = issuesByColumn[column] + // Todo column renders as a tree (prerequisite chains -> indented + // children). Other columns stay flat. Keep `ids` aligned with the + // visual order so dnd-kit keyboard nav / sortable indices match. + const ordered: Array<{ issue: Issue, depth: number }> + = column === 'todo' + ? buildTodoTree(columnIssues) + : columnIssues.map(issue => ({ issue, depth: 0 })) + const ids = ordered.map(o => o.issue.id) + return ( + + + {ordered.map(({ issue, depth }) => { + const { locked, prerequisiteNumber } = isIssueLocked(issue, issues) + return ( + { + if (node) + cardRefs.current.set(issue.id, node) + else + cardRefs.current.delete(issue.id) + }} + issue={issue} + depth={depth} + selected={issue.id === selectedId} + onSelect={onSelectIssue} + locked={locked} + lockedPrerequisite={prerequisiteNumber} + /> + ) + })} + + + ) + })} +
+ + + {activeIssue ? : null} + +
+ ) +} diff --git a/vscode/webview-ui/src/components/KanbanColumn.tsx b/vscode/webview-ui/src/components/KanbanColumn.tsx new file mode 100644 index 0000000..4278acc --- /dev/null +++ b/vscode/webview-ui/src/components/KanbanColumn.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from 'react' +import { useDroppable } from '@dnd-kit/core' +import { Plus } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Badge } from './ui/badge' +import type { Issue, IssueColumn } from '../types' +import { COLUMN_LABELS } from '../types' + +interface KanbanColumnProps { + column: IssueColumn + issues: Issue[] + children: ReactNode + /** When provided, renders a + button in the header. Used for the todo column. */ + onCreate?: () => void +} + +export function KanbanColumn({ column, issues, children, onCreate }: KanbanColumnProps) { + const { setNodeRef, isOver } = useDroppable({ id: column }) + + return ( +
+
+ + {COLUMN_LABELS[column]} + +
+ {issues.length} + {onCreate && ( + + )} +
+
+ +
+ {issues.length === 0 + ? ( +
+ 拖到这里 +
+ ) + : ( + children + )} +
+
+ ) +} diff --git a/vscode/webview-ui/src/components/LogModal.tsx b/vscode/webview-ui/src/components/LogModal.tsx new file mode 100644 index 0000000..ceede36 --- /dev/null +++ b/vscode/webview-ui/src/components/LogModal.tsx @@ -0,0 +1,215 @@ +/** + * In-webview modal for browsing extension logs (forwarded from the host + * Logger over the panel message bridge). + * + * Behaviour: + * - Backdrop click is intentionally a no-op (matches the new-issue modal). + * - ESC key closes the modal. + * - Entries render newest-at-bottom. Auto-scrolls only when the user is + * already pinned near the bottom — preserves position when they have + * scrolled up to inspect. + * - "复制全部" dumps a plain-text rendering to the clipboard. + */ + +import type { ReactElement } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Copy, Trash2, X } from 'lucide-react' +import type { LogEntry } from '../lib/messages' + +export interface LogModalProps { + open: boolean + entries: LogEntry[] + onClose: () => void + onClear: () => void +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n) +} + +function formatTime(ts: number): string { + const d = new Date(ts) + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}` +} + +function levelClass(level: LogEntry['level']): string { + if (level === 'error') + return 'text-[var(--vscode-errorForeground,#f48771)]' + if (level === 'warn') + return 'text-yellow-400' + return 'opacity-80' +} + +function formatPlain(entries: LogEntry[]): string { + return entries + .map((e) => { + const head = `${formatTime(e.ts)} [${e.level.toUpperCase()}] [${e.source}] ${e.message}` + return e.details ? `${head}\n ${e.details}` : head + }) + .join('\n') +} + +export function LogModal({ open, entries, onClose, onClear }: LogModalProps): ReactElement | null { + const scrollRef = useRef(null) + const pinnedRef = useRef(true) + const [copied, setCopied] = useState(false) + + // ESC to close. + useEffect(() => { + if (!open) + return + function onKey(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [open, onClose]) + + // Track whether the user is currently pinned to the bottom (within ~50px). + useEffect(() => { + const el = scrollRef.current + if (!el) + return + function onScroll(): void { + if (!el) + return + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + pinnedRef.current = distance < 50 + } + el.addEventListener('scroll', onScroll) + return () => el.removeEventListener('scroll', onScroll) + }, [open]) + + // Auto-scroll only when the user is already near the bottom. + useLayoutEffect(() => { + if (!open) + return + const el = scrollRef.current + if (!el) + return + if (pinnedRef.current) + el.scrollTop = el.scrollHeight + }, [entries.length, open]) + + // Reset the "copied" flag whenever the modal opens. + useEffect(() => { + if (open) + setCopied(false) + }, [open]) + + if (!open) + return null + + async function handleCopy(): Promise { + try { + await navigator.clipboard.writeText(formatPlain(entries)) + setCopied(true) + window.setTimeout(() => setCopied(false), 2000) + } + catch { + // Clipboard might be unavailable in some VS Code webview contexts — + // swallow silently, the user can still read logs on screen. + } + } + + return ( +
+
+ {/* Header */} +
+

日志

+ + {entries.length} + {' 条'} + + +
+ + {/* Body */} +
+ {entries.length === 0 + ? ( +
+ 暂无日志 +
+ ) + : ( + entries.map((e, idx) => ( + // ts is monotonic enough at ms resolution for our cap of 500 + // entries; fall back to idx if two collide. +
+
+ {formatTime(e.ts)} + {' '} + + [ + {e.level.toUpperCase()} + ] + + {' '} + + [ + {e.source} + ] + + {' '} + {e.message} +
+ {e.details && ( +
{e.details}
+ )} +
+ )) + )} +
+ + {/* Footer */} +
+ + + {copied && ( + 已复制 + )} + +
+
+
+ ) +} diff --git a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx new file mode 100644 index 0000000..52a1a6a --- /dev/null +++ b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx @@ -0,0 +1,293 @@ +/** + * 项目 Claude 会话管理器(底部第三个 tab)。 + * + * - 上方表单:选 profile(下拉,复用 useIssues 的 ClaudeProfile 列表)+ 首个提示词 + * (可选,多行)+「创建」按钮 → 主进程开一个 cc 终端 tab 并捕获会话。 + * - 下方列表:每行显示名字(双击行内改名,Enter 提交、Esc 取消)、profile 文件名、 + * 创建时间;点击行(非编辑区)→ 主进程新终端 resume;行尾删除按钮(仅移除记录)。 + */ + +import type { ClaudeProfile } from '../hooks/useIssues' +import type { ManagedSession, ManagedSessionsData } from '../types' +import { Plus, Terminal, Trash2, X } from 'lucide-react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +interface ManagedSessionsPanelProps { + data: ManagedSessionsData + profiles: ClaudeProfile[] + onCreate: (profilePath: string, name?: string, prompt?: string) => void + onRename: (id: string, name: string) => void + onResume: (id: string) => void + onDelete: (id: string) => void + onCloseTab: (id: string) => void +} + +function basename(p?: string): string { + if (!p) + return '' + const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + return idx >= 0 ? p.slice(idx + 1) : p +} + +function formatTime(ts: number): string { + if (!ts) + return '' + try { + return new Date(ts).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + catch { + return '' + } +} + +export function ManagedSessionsPanel({ data, profiles, onCreate, onRename, onResume, onDelete, onCloseTab }: ManagedSessionsPanelProps) { + const [selectedProfile, setSelectedProfile] = useState('') + const [name, setName] = useState('') + const [prompt, setPrompt] = useState('') + + // 默认选中官方 profile(profiles 异步到达后;用户手动改选后不覆盖)。 + useEffect(() => { + if (selectedProfile || profiles.length === 0) + return + const official = profiles.find(p => p.name.toLowerCase() === 'offical') + setSelectedProfile((official ?? profiles[0]).path) + }, [profiles, selectedProfile]) + + const sessions = useMemo( + () => [...data.sessions].sort((a, b) => b.createdAt - a.createdAt), + [data.sessions], + ) + + const canCreate = selectedProfile.trim() !== '' + + const handleCreate = useCallback((): void => { + if (!canCreate) + return + const trimmedName = name.trim() + const trimmedPrompt = prompt.trim() + onCreate(selectedProfile, trimmedName || undefined, trimmedPrompt || undefined) + setName('') + setPrompt('') + }, [canCreate, name, prompt, selectedProfile, onCreate]) + + return ( +
+ {/* 上方表单 */} +
+
+
+ Profile + +
+
+ 名字 + setName(e.target.value)} + placeholder="会话名字(可选)" + className="min-w-0 flex-1 rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] outline-none placeholder:text-[var(--vscode-input-placeholderForeground)] focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]" + /> +
+
+ 提示词 +