diff --git a/CHANGELOG.md b/CHANGELOG.md index 1543ed9..67c6c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,12 @@ ### Changed -- 聊天面板(PR Agent)内部重构:2578 行的单文件 `ChatPane.tsx` 按「容器 / 领域组件 / hooks / 工具方法」分层拆分到 `components/chat/`,状态与生命周期、业务动作、时间线归并各自成 hook,展示组件与工具方法独立成文件。对外接口与界面行为保持不变,仅改善可维护性;token 用量 ↑/↓ 的绿红色值一并从内联样式收进设计令牌与样式类。 -- 首启向导内部重构:`OnboardingWizard.tsx` 的四个步骤组件(欢迎 / 平台 / LLM / 完成)拆分到 `steps/` 各自成文件,容器只留向导骨架(步骤指示 + 切换 + 导航)。对外接口与界面行为不变。 -- `components/` 目录按职责重组:扁平堆叠的组件归入三类——`common/`(基础公共 UI)、`layout/`(应用骨架)、`features/`(业务领域:pr / diff / comments / drafts / settings / chat / onboarding),顶层只剩这三个桶。纯文件位置调整 + import 路径改写,无逻辑 / 界面变更。 +- **前端代码结构重构(可维护性)**:纯结构调整,对外接口与界面 / 交互行为均不变。重点: + - 组件按 `common/`(基础 UI)/ `layout/`(应用骨架)/ `features/`(业务领域)三层归类;样式 `styles/` 同构归并 + - 超大组件按「容器 + 领域组件 + hooks + 工具方法」分层拆分:ChatPane、SettingsModal、MainPane、StatusBar + - 业务逻辑下沉所属领域:PR 列表 / 详情 / 工作区归 `features/pr`;App 主入口退化为组合根,启动 / 布局 / 更新提示等拆成 app 级 hooks + - 抽出通用基础组件 `Modal` / `StatusChip`;状态栏 chip 按归属下沉到各 feature + - 其它整理:目录归并、工具方法去重、main 进程 splash 拆分 ### Fixed diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6fbf797..6bdece0 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,6 +1,5 @@ import { app, BrowserWindow, Menu, nativeTheme, shell } from 'electron'; import path from 'node:path'; -import { readFileSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import type { Logger } from 'pino'; @@ -14,6 +13,7 @@ import { RepoMirrorManager } from '@meebox/repo-mirror'; import type { PlatformAdapter, PlatformUser, PrAgentStatus } from '@meebox/shared'; import { JsonFileStateStore } from '@meebox/state-store'; import { buildAdapters, type ConnectionRuntime } from './adapters.js'; +import { createSplash } from './splash.js'; import { initMainI18n } from './i18n/index.js'; import { registerIpcHandlers } from './ipc.js'; import { buildProxyEnv } from './utils/proxy.js'; @@ -438,31 +438,6 @@ app.on('before-quit', (event) => { setTimeout(() => app.quit(), 800); }); -/** - * 读取品牌 logo 并转成 base64 data URI,内联进 splash data URL(splash 是独立 data URL - * 文档,无法走 file:// 相对路径引用资源,故必须内联)。两路探测: - * - 打包态:`/icon.png`(electron-builder extraResources copy) - * - dev:仓库 `assets/icons/icon.png` - * 两路都读不到(如 LFS 未拉取)则返回 null,splash 优雅回退为纯 spinner。 - */ -function resolveSplashLogo(): string | null { - const candidates = [ - path.join(process.resourcesPath, 'icon.png'), - path.join(app.getAppPath(), '../../assets/icons/icon.png'), - ]; - for (const p of candidates) { - try { - const buf = readFileSync(p); - // LFS 指针文件不是合法 PNG(无 \x89PNG magic)→ 跳过,避免 splash 显示裂图 - if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50) continue; - return `data:image/png;base64,${buf.toString('base64')}`; - } catch { - /* 试下一个候选 */ - } - } - return null; -} - /** * 版本更新检测(config.update.check_enabled 开启时)。由 poller tick 顺带发起,内部用 * lastUpdateCheckMs 时间戳门控成「至多每小时一次」——复用既有 poll 周期,不引入额外定时器。 @@ -497,49 +472,6 @@ async function runUpdateCheckIfDue(): Promise { } } -/** - * 启动闪屏:独立的无边框轻量窗口,加载内联 data URL(品牌 logo + 纯 CSS spinner), - * 几十 ms 即可呈现,遮住主窗口首帧前的渲染层加载空窗。主窗口 ready-to-show 时关闭。 - * logo 经 base64 内联(见 resolveSplashLogo),data URL 自包含、dev/打包行为一致。 - */ -function createSplash(): BrowserWindow { - const splash = new BrowserWindow({ - width: 280, - height: 240, - frame: false, - resizable: false, - movable: false, - center: true, - show: false, - alwaysOnTop: true, - skipTaskbar: true, - backgroundColor: '#1e1e1e', - webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, - }); - const logo = resolveSplashLogo(); - const logoEl = logo ? `` : ''; - const html = ` - ${logoEl}
Code Meeseeks
-
启动中…
- `; - void splash.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)); - splash.once('ready-to-show', () => { - if (!splash.isDestroyed()) splash.show(); - }); - return splash; -} - function createWindow(splash?: BrowserWindow): void { // 最小尺寸保证核心三栏 (sidebar 240 + file-tree 180 + diff 内容) // 在 chat-pane 折叠态下仍可用;高度兜住 pr-header + tabs + diff + statusbar diff --git a/apps/desktop/src/main/splash.ts b/apps/desktop/src/main/splash.ts new file mode 100644 index 0000000..a742119 --- /dev/null +++ b/apps/desktop/src/main/splash.ts @@ -0,0 +1,71 @@ +import { app, BrowserWindow } from 'electron'; +import path from 'node:path'; +import { readFileSync } from 'node:fs'; + +/** + * 读取品牌 logo 并转成 base64 data URI,内联进 splash data URL(splash 是独立 data URL + * 文档,无法走 file:// 相对路径引用资源,故必须内联)。两路探测: + * - 打包态:`/icon.png`(electron-builder extraResources copy) + * - dev:仓库 `assets/icons/icon.png` + * 两路都读不到(如 LFS 未拉取)则返回 null,splash 优雅回退为纯 spinner。 + */ +function resolveSplashLogo(): string | null { + const candidates = [ + path.join(process.resourcesPath, 'icon.png'), + path.join(app.getAppPath(), '../../assets/icons/icon.png'), + ]; + for (const p of candidates) { + try { + const buf = readFileSync(p); + // LFS 指针文件不是合法 PNG(无 \x89PNG magic)→ 跳过,避免 splash 显示裂图 + if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50) continue; + return `data:image/png;base64,${buf.toString('base64')}`; + } catch { + /* 试下一个候选 */ + } + } + return null; +} + +/** + * 启动闪屏:独立的无边框轻量窗口,加载内联 data URL(品牌 logo + 纯 CSS spinner), + * 几十 ms 即可呈现,遮住主窗口首帧前的渲染层加载空窗。主窗口 ready-to-show 时关闭。 + * logo 经 base64 内联(见 resolveSplashLogo),data URL 自包含、dev/打包行为一致。 + */ +export function createSplash(): BrowserWindow { + const splash = new BrowserWindow({ + width: 280, + height: 240, + frame: false, + resizable: false, + movable: false, + center: true, + show: false, + alwaysOnTop: true, + skipTaskbar: true, + backgroundColor: '#1e1e1e', + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + }); + const logo = resolveSplashLogo(); + const logoEl = logo ? `` : ''; + const html = ` + ${logoEl}
Code Meeseeks
+
启动中…
+ `; + void splash.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)); + splash.once('ready-to-show', () => { + if (!splash.isDestroyed()) splash.show(); + }); + return splash; +} diff --git a/apps/desktop/src/renderer/src/App.scss b/apps/desktop/src/renderer/src/App.scss index d09351b..7fbe96c 100644 --- a/apps/desktop/src/renderer/src/App.scss +++ b/apps/desktop/src/renderer/src/App.scss @@ -15,18 +15,18 @@ // drafts-panel "草稿" tab 列表页 (.drafts-panel / -filter / -item / -anchor) // modal SettingsModal + LlmEditorModal 子模态 + LLM profile 列表 @use './styles/base'; -@use './styles/titlebar'; -@use './styles/statusbar'; -@use './styles/sidebar'; -@use './styles/main-pane'; -@use './styles/file-tree'; -@use './styles/diff'; -@use './styles/diff-search'; -@use './styles/comment-zone'; -@use './styles/draft-zone'; +@use './styles/layout/titlebar'; +@use './styles/layout/statusbar'; +@use './styles/layout/sidebar'; +@use './styles/layout/main-pane'; +@use './styles/features/file-tree'; +@use './styles/features/diff'; +@use './styles/features/diff-search'; +@use './styles/features/comment-zone'; +@use './styles/features/draft-zone'; @use './styles/markdown'; -@use './styles/pr-info'; -@use './styles/chat-pane'; -@use './styles/drafts-panel'; -@use './styles/modal'; -@use './styles/onboarding'; +@use './styles/features/pr-info'; +@use './styles/features/chat-pane'; +@use './styles/features/drafts-panel'; +@use './styles/common/modal'; +@use './styles/features/onboarding'; diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 4259f41..74828db 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,371 +1,69 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import i18n, { resolveUiLanguage, persistLanguage } from './i18n'; -import type { - AppInfo, - AppPaths, - Config, - ConnectionSummary, - LocalPrStatus, - PrAgentStatus, - PrDiscoveryFilter, - StoredPullRequest, - UpdateCheckResult, -} from '@meebox/shared'; -import { invoke, subscribe } from './api'; -import { ChatPane, CHAT_MAX_WIDTH, CHAT_MIN_WIDTH } from './components/features/chat'; -import { wireChatRunStore } from './stores/chat-run-store'; -import { wireDraftsStore } from './stores/drafts-store'; -import { wireRepoSyncStore } from './stores/repo-sync-store'; +import type { PrDiscoveryFilter } from '@meebox/shared'; +import { invoke } from './api'; +import { ChatPane } from './components/features/chat'; import { MainPane } from './components/layout/MainPane'; -import { OnboardingWizard, type OnboardingResult } from './components/features/onboarding/OnboardingWizard'; -import { SettingsModal } from './components/features/settings/SettingsModal'; -import { Sidebar, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH } from './components/layout/Sidebar'; +import { PrPanel, PrEmpty, usePullRequests } from './components/features/pr'; +import { OnboardingWizard } from './components/features/onboarding'; +import { SettingsModal } from './components/features/settings'; +import { Sidebar } from './components/layout/Sidebar'; import { StatusBar } from './components/layout/StatusBar'; import { TitleBar } from './components/layout/TitleBar'; - -interface BootstrapState { - info: AppInfo; - paths: AppPaths; - config: Config; - prAgent: PrAgentStatus; - connections: ConnectionSummary[]; - lastSyncAt: string | null; -} +import { useToast } from './hooks/useToast'; +import { useBootstrap } from './hooks/useBootstrap'; +import { usePanelLayout } from './hooks/usePanelLayout'; +import { useUpdateNotice } from './hooks/useUpdateNotice'; +import { useAppStores } from './hooks/useAppStores'; +import { useExternalLinkGuard } from './hooks/useExternalLinkGuard'; export default function App() { const { t } = useTranslation(); - const [boot, setBoot] = useState(null); - const [prs, setPrs] = useState([]); - const [selectedId, setSelectedId] = useState(null); - const [refreshing, setRefreshing] = useState(false); - // 合并进行中:GitHub 合并可能较慢(异步算 mergeable),按钮置等待态并防重复点击。 - const [merging, setMerging] = useState(false); + const { toast, notifyError, dismiss: dismissToast } = useToast(); + // PR 列表 / 选中 / 审批 / 合并 / 刷新 —— 领域逻辑归 usePullRequests + const { + prs, + setPrs, + selectedId, + setSelectedId, + selected, + refreshing, + merging, + reloadPrs, + triggerRefresh, + setSelectedPrStatus, + mergeSelectedPr, + } = usePullRequests({ notifyError }); + // 应用启动 / 全局生命周期(boot 加载、语言、poll / focus 刷新、向导完成、连接热生效) + const { boot, fatalError, lastSyncAt, needsOnboarding, completeOnboarding, refreshBootAndPrs, patchConfig } = + useBootstrap({ setPrs, reloadPrs }); + // 布局态(左右两栏宽度 / 折叠)、版本更新提示、store 接线、外链防护——各自成 app 级 hook + const { + sidebarWidth, + setSidebarWidth, + sidebarCollapsed, + setSidebarCollapsed, + chatWidth, + setChatWidth, + chatCollapsed, + setChatCollapsed, + } = usePanelLayout(); + const updateInfo = useUpdateNotice(); + useAppStores(); + useExternalLinkGuard(); + const [showSettings, setShowSettings] = useState(false); - const [fatalError, setFatalError] = useState(null); - // 启动检测到的新版本(main 推 app:updateAvailable);StatusBar 据此提示跳转下载。 - const [updateInfo, setUpdateInfo] = useState(null); - // 操作级 toast(审批 / 合并等远端动作失败时提示,区别于 fatalError 整屏报错)。 - // key 用随机数:同样文案连续触发也能重置自动消失计时器。 - const [toast, setToast] = useState<{ text: string; key: number } | null>(null); - const notifyError = useCallback((text: string): void => { - setToast({ text, key: Math.random() }); - }, []); - // toast 自动消失(6s);key 变化即重置计时 - useEffect(() => { - if (!toast) return; - const id = setTimeout(() => setToast(null), 6000); - return () => clearTimeout(id); - // 仅依赖 key:同一 toast 重渲不重置计时,新 toast (key 变) 才重置 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toast?.key]); - // 仅调试用:localStorage 里 meebox.forceOnboarding='1' 时强制进首启向导, - // 不必动 config.yaml。DevTools 设值后刷新进入;走完向导会自动清掉该 flag。 - // 详见 docs/development.md。 - const [forceOnboarding, setForceOnboarding] = useState( - () => localStorage.getItem('meebox.forceOnboarding') === '1', - ); /** - * M4 跨组件跳转:ChatPane finding card 点"编辑" → 这里 set → - * MainPane 切 tab='diff' + 透传给 DiffView → DiffView 消费完调 onConsumed 清空。 - * 一次性 token;非 null 时 DiffView 应该 scroll + highlight (+ open edit zone - * 如果带 runId/findingId 能反查到 finding-source 草稿)。 - * - * runId/findingId 可选: - * - ChatPane finding card 跳转 → 必带,DiffView 据此找草稿自动 enter edit - * - PublishReviewModal anchor 点击 → 只带 anchor,DiffView 仅 navigate 不进 edit - * (用户在 modal 里看到某条想确认上下文,跳过去看一眼,不一定要改) + * M4 跨组件跳转:ChatPane finding card 点"编辑" / PublishReviewModal anchor 点击 → 这里 set → + * PrPanel 切到 Diff tab + 透传给 DiffView 做 scroll/highlight/(可选)open edit zone,消费完清空。 */ const [pendingDiffNav, setPendingDiffNav] = useState<{ runId?: string; findingId?: string; anchor: { path: string; startLine: number; endLine: number }; } | null>(null); - const [lastSyncAt, setLastSyncAt] = useState(null); - // GitHub 发现分类(运行时筛选,不持久化);仅 GitHub 活动连接时在 PR 列表展示。 + // GitHub 发现分类(运行时筛选,不持久化);仅活动连接支持时在 PR 列表展示。 const [discoveryFilter, setDiscoveryFilter] = useState('review-requested'); - const [sidebarWidth, setSidebarWidth] = useState(() => { - const raw = localStorage.getItem('meebox.sidebarWidth'); - const n = raw ? Number(raw) : 360; - return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Number.isFinite(n) ? n : 360)); - }); - const [sidebarCollapsed, setSidebarCollapsed] = useState( - () => localStorage.getItem('meebox.sidebarCollapsed') === '1', - ); - const [chatWidth, setChatWidth] = useState(() => { - const raw = localStorage.getItem('meebox.chatWidth'); - const n = raw ? Number(raw) : 360; - return Math.min(CHAT_MAX_WIDTH, Math.max(CHAT_MIN_WIDTH, Number.isFinite(n) ? n : 360)); - }); - const [chatCollapsed, setChatCollapsed] = useState( - // 默认收起:M3 之前 chat 还是空壳,避免空占地方 - () => (localStorage.getItem('meebox.chatCollapsed') ?? '1') === '1', - ); - useEffect(() => { - localStorage.setItem('meebox.sidebarWidth', String(sidebarWidth)); - }, [sidebarWidth]); - useEffect(() => { - localStorage.setItem('meebox.sidebarCollapsed', sidebarCollapsed ? '1' : '0'); - }, [sidebarCollapsed]); - useEffect(() => { - localStorage.setItem('meebox.chatWidth', String(chatWidth)); - }, [chatWidth]); - useEffect(() => { - localStorage.setItem('meebox.chatCollapsed', chatCollapsed ? '1' : '0'); - }, [chatCollapsed]); - - const reloadPrs = useCallback(async (): Promise => { - const fresh = await invoke('prs:list', undefined); - setPrs(fresh); - }, []); - - // 连接改动(尤其切换活动连接)后整体刷新 boot:活动连接变化后 main 端 app:connections / - // prs:list 都随之变,必须重拉,否则 boot.connections、PR 列表会过期。 - const refreshBootAndPrs = useCallback(async (): Promise => { - const [config, connections, freshPrs, lastSync] = await Promise.all([ - invoke('config:read', undefined), - invoke('app:connections', undefined), - invoke('prs:list', undefined), - invoke('prs:lastSync', undefined), - ]); - setBoot((b) => (b ? { ...b, config, connections, lastSyncAt: lastSync.at } : b)); - setPrs(freshPrs); - setLastSyncAt(lastSync.at); - }, []); - - useEffect(() => { - void (async () => { - try { - if (!window.api) { - throw new Error('preload bridge missing: window.api is undefined'); - } - const [info, paths, config, prAgent, initialPrs, connections, lastSync] = await Promise.all( - [ - invoke('app:info', undefined), - invoke('app:paths', undefined), - invoke('config:read', undefined), - invoke('app:prAgentStatus', undefined), - invoke('prs:list', undefined), - invoke('app:connections', undefined), - invoke('prs:lastSync', undefined), - ], - ); - // 先按 config.language 切到目标语言并**等其资源加载完**(懒加载语言会异步拉 chunk), - // 再 setBoot 渲染主界面 —— 首屏直接是用户语言,不闪兜底。persist 供下次启动同步命中。 - const lang = resolveUiLanguage(config.language); - persistLanguage(lang); - await i18n.changeLanguage(lang); - setBoot({ info, paths, config, prAgent, connections, lastSyncAt: lastSync.at }); - setPrs(initialPrs); - setLastSyncAt(lastSync.at); - } catch (e) { - setFatalError(e instanceof Error ? e.message : String(e)); - } - })(); - }, []); - - // 运行时语言切换(如设置页改 config.language):boot 后 language 变化即切换并回写持久化。 - // 首次 boot 时已在上面 await 切好,这里对同值是幂等 no-op。 - useEffect(() => { - if (!boot) return; - const lang = resolveUiLanguage(boot.config.language); - persistLanguage(lang); - void i18n.changeLanguage(lang); - }, [boot]); - - // 启动时把 pr-agent 活动 run + 实时 stdout 流接到全局 store;ChatPane 跨 PR - // 切换时可读 store 拿回运行中的状态 (本组件挂载到树根,效果等价于"应用级 hook") - useEffect(() => wireChatRunStore(), []); - // 同样思路:把 repo sync 事件流接到 store,StatusBar 任意时刻可读当前活动同步任务 - useEffect(() => wireRepoSyncStore(), []); - // M4 草稿事件 → store;写盘后 drafts:changed 触发指定 PR 的草稿列表自动刷新 - useEffect(() => wireDraftsStore(), []); - // 版本更新:main 为单一真相源。挂载时先水合已缓存结果(设置页手动检查 / 定时检查到的新版 - // 不会因窗口重挂载而丢失),再订阅后续广播(手动与定时检查都经此推送)。 - useEffect(() => { - void invoke('app:getUpdateStatus', undefined).then((info) => { - if (info) setUpdateInfo(info); - }); - return subscribe('app:updateAvailable', (info) => setUpdateInfo(info)); - }, []); - // dev 调试钩子:控制台 dispatch CustomEvent 模拟「发现新版」以验证状态栏 chip - // (dev 版本通常高于 latest,自然不会触发)。detail=null 清除。 - // window.dispatchEvent(new CustomEvent('meebox:debug-update')) - // window.dispatchEvent(new CustomEvent('meebox:debug-update', { detail: { latestVersion: '1.2.3' } })) - // window.dispatchEvent(new CustomEvent('meebox:debug-update', { detail: null })) - useEffect(() => { - const onDebug = (e: Event): void => { - const d = (e as CustomEvent | null>).detail; - setUpdateInfo( - d === null - ? null - : { - ok: true, - hasUpdate: true, - currentVersion: '0.0.0', - latestVersion: '9.9.9', - url: 'https://github.com/huhamhire/code-meeseeks/releases/latest', - ...d, - }, - ); - }; - window.addEventListener('meebox:debug-update', onDebug); - return () => window.removeEventListener('meebox:debug-update', onDebug); - }, []); - - // 全局外链跳转防护 — 所有 UGC 场景 (评论 / PR 描述 / finding / chat 等) 内 - // 的 点击都走系统默认浏览器,不允许 Electron 在 app - // window 内直接跳转覆盖整个界面。capture 阶段 listener 先于 React onClick 跑 - useEffect(() => { - const onClick = (e: MouseEvent): void => { - const target = (e.target as HTMLElement | null)?.closest?.('a[href]'); - if (!(target instanceof HTMLAnchorElement)) return; - const href = target.getAttribute('href'); - if (!href || !/^https?:\/\//.test(href)) return; - e.preventDefault(); - e.stopPropagation(); - void invoke('app:openExternal', { url: href }); - }; - document.addEventListener('click', onClick, true); - return () => document.removeEventListener('click', onClick, true); - }, []); - - // 窗口重新获得焦点时主动 refresh 远端:调 prs:refresh 拉 PR meta,Bitbucket 上 - // 加 comment / 改状态后 PR.updatedAt 跳变 → MainPane useEffect 的 prUpdatedAt - // dep 触发 → force listComments 拉到新评论。比纯 reloadPrs (只读 cache) 多 - // 一次远端调用但能跟上"用户切到 Bitbucket 评论再切回应用"的常见场景 - useEffect(() => { - const onFocus = (): void => { - if (!boot) return; - void (async () => { - try { - await invoke('prs:refresh', undefined); - await reloadPrs(); - } catch { - // 静默:focus 触发的刷新失败不该弹错给用户 - } - })(); - }; - window.addEventListener('focus', onFocus); - return () => window.removeEventListener('focus', onFocus); - }, [boot, reloadPrs]); - - // 订阅 main 推送的 poll tick;用于刷新 statusbar "最近同步" 显示, - // 并顺便重拉一次 PR 列表使后台轮询新增/删除立刻反映在 UI。 - // 同时刷新连接摘要:启动时连接的 ping(缓存 currentUser)在建窗后才完成,首轮 tick 即随其后, - // 借此把状态栏用户/能力位补上(否则需手动刷新才显示)。app:connections 为廉价同步调用。 - useEffect(() => { - if (!window.api) return; - return subscribe('poll:tick', (info) => { - setLastSyncAt(info.at); - void reloadPrs(); - void invoke('app:connections', undefined).then( - (connections) => { - setBoot((b) => (b ? { ...b, connections } : b)); - }, - () => { - /* 摘要刷新失败不影响主流程 */ - }, - ); - }); - }, [reloadPrs]); - - const triggerRefresh = useCallback(async (): Promise => { - if (refreshing) return; - setRefreshing(true); - try { - await invoke('prs:refresh', undefined); - await reloadPrs(); - } catch (e) { - console.error('refresh failed', e); - } finally { - setRefreshing(false); - } - }, [refreshing, reloadPrs]); - - const selected = prs.find((p) => p.localId === selectedId) ?? null; - // 选中 PR 所属连接的能力位 + 当前 PAT 用户(多平台降级:审批按钮显隐 / 自己 PR 灰显) - const selectedConn = selected - ? boot?.connections.find((c) => c.connectionId === selected.connectionId) - : undefined; - - const setSelectedPrStatus = useCallback( - async (status: LocalPrStatus): Promise => { - if (!selected) return; - try { - const updated = await invoke('prs:setLocalStatus', { - localId: selected.localId, - status, - }); - if (updated) { - setPrs((prev) => prev.map((p) => (p.localId === updated.localId ? updated : p))); - } - } catch (e) { - // 远端拒绝(如 PR 已关闭 / 合并 / 权限不足)→ 本地状态不变,弹 toast 提示。 - // 顺手刷新一次:PR 若已关闭,下一轮 poll 会把它软删,列表自洽 - const msg = e instanceof Error ? e.message : String(e); - notifyError(t('app.approveActionFailed', { msg })); - void triggerRefresh(); - } - }, - [selected, notifyError, triggerRefresh, t], - ); - - const mergeSelectedPr = useCallback(async (): Promise => { - if (!selected || merging) return; - const mergedId = selected.localId; - setMerging(true); - try { - await invoke('prs:merge', { localId: mergedId }); - } catch (e) { - // 合并失败(冲突 / veto / 权限 / PR 已关闭)→ 弹 toast,本地不变 - const msg = e instanceof Error ? e.message : String(e); - notifyError(t('app.mergeFailed', { msg })); - void triggerRefresh(); - return; - } finally { - setMerging(false); - } - // 合并成功:PR 已转 MERGED,会从 pending 列表退场。取消选中 + 刷新让其消失 - if (selectedId === mergedId) setSelectedId(null); - await triggerRefresh(); - }, [selected, selectedId, triggerRefresh, notifyError, merging, t]); - - // 首启向导完成:落盘连接(必)+ LLM / 缓存目录(按需),再重拉配置/连接/PR 更新 - // boot。boot.config 拿到有效 active 连接后,下方 needsOnboarding 派生为 false, - // 向导自然卸载、切入主界面;主界面挂载后 poll:tick 订阅 + focus 刷新自然生效。 - const completeOnboarding = useCallback( - async (result: OnboardingResult): Promise => { - await invoke('config:setConnections', { - connections: [result.connection], - active_connection_id: result.connection.id, - }); - if (result.llm) { - await invoke('config:setLlm', { - llm: { profiles: [result.llm], active_id: result.llm.id }, - }); - } - const trimmedRepos = result.reposDir.trim(); - if (trimmedRepos && trimmedRepos !== (boot?.config.workspace.repos_dir ?? '')) { - await invoke('config:setReposDir', { reposDir: trimmedRepos }); - } - const [config, connections, freshPrs, lastSync] = await Promise.all([ - invoke('config:read', undefined), - invoke('app:connections', undefined), - invoke('prs:list', undefined), - invoke('prs:lastSync', undefined), - ]); - setBoot((b) => (b ? { ...b, config, connections, lastSyncAt: lastSync.at } : b)); - setPrs(freshPrs); - setLastSyncAt(lastSync.at); - // 走完向导清掉调试 flag,避免强制模式下完成后仍被困在向导 - if (forceOnboarding) { - localStorage.removeItem('meebox.forceOnboarding'); - setForceOnboarding(false); - } - }, - [boot, forceOnboarding], - ); if (fatalError) { return ( @@ -374,7 +72,6 @@ export default function App() { ); } - if (!boot) { return (
@@ -382,12 +79,6 @@ export default function App() {
); } - - // gate 条件 = 有无「有效的 active 连接」:连接为空 / active 悬空都触发首启向导。 - // 不依赖一次性 firstRun 标记 —— 用户清空连接后下次进入仍会回到向导。 - const needsOnboarding = - forceOnboarding || - !boot.config.connections.some((c) => c.id === boot.config.active_connection_id); if (needsOnboarding) { return ( c.connectionId === selected.connectionId) + : undefined; // 有 active 连接但 LLM 未配置 → ChatPane 给出「需配置才能启用」提示并禁用输入 const llmConfigured = boot.config.llm.profiles.some((p) => p.id === boot.config.llm.active_id); - // 发现分类标签由活动连接的能力决定(GitHub 四类、Bitbucket 两类、其余无)。 const activeConnSummary = boot.connections.find( (c) => c.connectionId === boot.config.active_connection_id, ); const availableDiscoveryFilters = activeConnSummary?.capabilities.discoveryFilters ?? []; const showDiscoveryFilter = availableDiscoveryFilters.length > 0; - // 选中的分类可能因切换连接而对当前平台无效(如 github 的 mentioned 切到 bitbucket)→ 回落首个可用。 + // 选中的分类可能因切换连接而对当前平台无效 → 回落首个可用。 const effectiveDiscoveryFilter = availableDiscoveryFilters.includes(discoveryFilter) ? discoveryFilter : availableDiscoveryFilters[0]; @@ -429,21 +123,24 @@ export default function App() { onDiscoveryFilterChange={showDiscoveryFilter ? setDiscoveryFilter : undefined} /> )} - 0} - onSetStatus={(s) => void setSelectedPrStatus(s)} - onMerge={() => void mergeSelectedPr()} - merging={merging} - capabilities={selectedConn?.capabilities} - currentUserName={selectedConn?.user?.name ?? null} - pendingDiffNav={pendingDiffNav} - onDiffNavConsumed={() => setPendingDiffNav(null)} - onRequestDiffNav={(target) => setPendingDiffNav(target)} - /> - {/* ChatPane 始终挂载,折叠只是 CSS 隐藏:保住运行中的 run 生命周期。 - 如果走条件渲染,折叠 = 卸载组件,进行中的计时器 / runProgress 订阅 - 全丢,再展开只能从持久化里看到已完成的结果 */} + + {selected ? ( + void setSelectedPrStatus(s)} + onMerge={() => void mergeSelectedPr()} + merging={merging} + capabilities={selectedConn?.capabilities} + currentUserName={selectedConn?.user?.name ?? null} + pendingDiffNav={pendingDiffNav} + onDiffNavConsumed={() => setPendingDiffNav(null)} + onRequestDiffNav={(target) => setPendingDiffNav(target)} + /> + ) : ( + 0} /> + )} + + {/* ChatPane 始终挂载,折叠只是 CSS 隐藏:保住运行中的 run 生命周期(计时器 / runProgress 订阅)。 */} setShowSettings(true)} - onJumpToDraftEditor={(t) => setPendingDiffNav(t)} + onJumpToDraftEditor={(target) => setPendingDiffNav(target)} onNavigateToAnchor={(anchor) => setPendingDiffNav({ anchor })} onSetReviewStatus={(s) => void setSelectedPrStatus(s)} - // 当前 active LLM profile.model — RunningView 显示成 chip 让用户知道 - // 这次 review 用的什么模型 (不同 profile 出的结果差异大) currentLlmModel={ boot.config.llm.profiles.find((p) => p.id === boot.config.llm.active_id)?.model ?? null } @@ -479,7 +174,7 @@ export default function App() { onSwitchActiveLlm={(id) => { const next = { ...boot.config.llm, active_id: id }; void invoke('config:setLlm', { llm: next }); - setBoot((b) => (b ? { ...b, config: { ...b.config, llm: next } } : b)); + patchConfig((c) => ({ ...c, llm: next })); }} onJumpToPr={setSelectedId} updateInfo={updateInfo} @@ -487,20 +182,10 @@ export default function App() { onToggleAutopilot={() => { const enabled = !boot.config.agent.autopilot.enabled; void invoke('agent:setAutopilotEnabled', { enabled }); - setBoot((b) => - b - ? { - ...b, - config: { - ...b.config, - agent: { - ...b.config.agent, - autopilot: { ...b.config.agent.autopilot, enabled }, - }, - }, - } - : b, - ); + patchConfig((c) => ({ + ...c, + agent: { ...c.agent, autopilot: { ...c.agent.autopilot, enabled } }, + })); }} /> {showSettings && ( @@ -508,13 +193,9 @@ export default function App() { info={boot.info} paths={boot.paths} config={boot.config} - onLlmChange={(llm) => setBoot((b) => (b ? { ...b, config: { ...b.config, llm } } : b))} - onProxyChange={(proxy) => - setBoot((b) => (b ? { ...b, config: { ...b.config, proxy } } : b)) - } - onLanguageChange={(language) => - setBoot((b) => (b ? { ...b, config: { ...b.config, language } } : b)) - } + onLlmChange={(llm) => patchConfig((c) => ({ ...c, llm }))} + onProxyChange={(proxy) => patchConfig((c) => ({ ...c, proxy }))} + onLanguageChange={(language) => patchConfig((c) => ({ ...c, language }))} onConnectionsChange={refreshBootAndPrs} onClose={() => setShowSettings(false)} /> @@ -523,7 +204,7 @@ export default function App() {
setToast(null)} + onClick={dismissToast} title={t('app.toastCloseTitle')} > {toast.text} diff --git a/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx b/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx index b93906e..5d823f4 100644 --- a/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx +++ b/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; +import { Modal } from './Modal'; interface ConfirmModalProps { title?: string; @@ -14,8 +14,7 @@ interface ConfirmModalProps { } /** - * 通用确认模态框,跟 SettingsModal 共用 .modal-backdrop / .modal 视觉语言。 - * 用 createPortal 渲染到 document.body 避开调用者所在层级(特别是 Monaco view + * 通用确认模态框,基于 Modal 壳。经 portal 渲染到 body 避开调用者层级(特别是 Monaco view * zone 内的 React tree,那一层 z-index 比 modal 低)。 * * 键盘:Esc 取消,Enter 确认(焦点默认在取消按钮,避免误触) @@ -47,32 +46,17 @@ export function ConfirmModal({ return () => window.removeEventListener('keydown', onKey); }, [onCancel, onConfirm]); - return createPortal( -
{ - e.stopPropagation(); - onCancel(); - }} - role="presentation" - > -
e.stopPropagation()} - role="dialog" - aria-modal="true" - aria-labelledby="confirm-modal-title" - > -
-

{resolvedTitle}

-
-
-

{message}

-
-
+ return ( + @@ -83,9 +67,10 @@ export function ConfirmModal({ > {resolvedConfirmLabel} -
-
-
, - document.body, + + } + > +

{message}

+ ); } diff --git a/apps/desktop/src/renderer/src/components/common/Modal.tsx b/apps/desktop/src/renderer/src/components/common/Modal.tsx new file mode 100644 index 0000000..b2d7a56 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/common/Modal.tsx @@ -0,0 +1,114 @@ +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import type { CSSProperties, ReactNode } from 'react'; +import { CloseIcon } from './icons'; + +type ModalSize = 'md' | 'sm' | 'confirm'; + +const SIZE_CLASS: Record = { + md: 'modal', + sm: 'modal modal-sm', + confirm: 'modal modal-confirm', +}; + +interface ModalProps { + onClose: () => void; + /** 弹窗尺寸 → 容器类名:md=modal · sm=modal-sm · confirm=modal-confirm */ + size?: ModalSize; + /** 二层嵌套模态:加 modal-backdrop-nested,背景点击 stopPropagation 防冒泡关掉外层模态 */ + nested?: boolean; + /** 经 createPortal 渲染到 body:避开调用者所在层级(如 Monaco view zone 内 z-index 偏低) */ + portal?: boolean; + /** 点背景是否关闭(默认 true) */ + closeOnBackdrop?: boolean; + /** 标题(不传则不渲染 header) */ + title?: ReactNode; + /** 标题元素 id,配合 aria-labelledby */ + titleId?: string; + /** header 右侧关闭按钮样式:图标按钮 / 文案按钮(不传则无) */ + headerClose?: 'icon' | 'text'; + /** modal-body 追加类名(如 confirm-body) */ + bodyClassName?: string; + /** 底部 footer 区内容(作为 modal-body 的兄弟渲染);不传则无 footer 区 */ + footer?: ReactNode; + /** footer 容器类名(默认 modal-footer-bar;确认框用 modal-actions) */ + footerClassName?: string; + /** 容器内联样式(个别弹窗自定宽度用) */ + style?: CSSProperties; + ariaLabel?: string; + ariaLabelledby?: string; + children: ReactNode; +} + +/** + * 通用模态壳:统一 backdrop(点外关闭 + 嵌套防冒泡)、dialog 容器、可选 header(标题 + 关闭键)、 + * modal-body 包裹、可选 footer 区。键盘交互(Esc / Enter)由各调用方按需自管——壳只负责结构与样式语言。 + */ +export function Modal({ + onClose, + size = 'md', + nested = false, + portal = false, + closeOnBackdrop = true, + title, + titleId, + headerClose, + bodyClassName, + footer, + footerClassName = 'modal-footer-bar', + style, + ariaLabel, + ariaLabelledby, + children, +}: ModalProps) { + const { t } = useTranslation(); + const tree = ( +
{ + e.stopPropagation(); + if (closeOnBackdrop) onClose(); + }} + role="presentation" + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={ariaLabel} + aria-labelledby={ariaLabelledby ?? titleId} + > + {title !== undefined && ( +
+

{title}

+ {headerClose === 'icon' && ( + + )} + {headerClose === 'text' && ( + + )} +
+ )} +
+ {children} +
+ {footer !== undefined &&
{footer}
} +
+
+ ); + return portal ? createPortal(tree, document.body) : tree; +} diff --git a/apps/desktop/src/renderer/src/components/common/StatusChip.tsx b/apps/desktop/src/renderer/src/components/common/StatusChip.tsx new file mode 100644 index 0000000..40e2c8b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/common/StatusChip.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; + +type ChipTone = 'ok' | 'err'; + +interface StatusChipProps { + /** 渲染元素:默认有 onClick 时为 button、否则 span */ + as?: 'span' | 'button'; + /** 语义色调 → statusbar-chip-ok / statusbar-chip-err */ + tone?: ChipTone; + /** 追加的专属类名(如 statusbar-pragent-chip / statusbar-llm-chip) */ + className?: string; + title?: string; + ariaLabel?: string; + ariaPressed?: boolean; + disabled?: boolean; + onClick?: () => void; + children: ReactNode; +} + +/** + * 状态栏 chip 通用壳:统一 `statusbar-chip` 基类 + 可选语义色调(ok/err),按是否可点 + * 渲染为 button / span,并透传 title / aria / disabled。各 chip 内部结构(图标 / 文案 / + * 下拉)作为 children 自管,专属样式经 className 追加。 + */ +export function StatusChip({ + as, + tone, + className, + title, + ariaLabel, + ariaPressed, + disabled, + onClick, + children, +}: StatusChipProps) { + const cls = ['statusbar-chip', tone && `statusbar-chip-${tone}`, className] + .filter(Boolean) + .join(' '); + const element = as ?? (onClick ? 'button' : 'span'); + if (element === 'button') { + return ( + + ); + } + return ( + + {children} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/FindingCard.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/FindingCard.tsx index e8d325b..c3094cc 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/FindingCard.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/FindingCard.tsx @@ -6,7 +6,7 @@ import remarkGfm from 'remark-gfm'; import type { Finding, PrDocSectionKey, ReviewDraft } from '@meebox/shared'; import { ChevronIcon } from '../../../common/icons'; import { mermaidComponents, walkthroughMdComponents } from '../../../common/markdownMermaid'; -import { REMOTE_REHYPE_PLUGINS } from '../../../../markdown'; +import { REMOTE_REHYPE_PLUGINS } from '../../../../lib/markdown'; import { translatePrAgentLabels } from '../../../../utils/translate-pr-agent'; import { pillStyle, diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx index ce57d3b..d9ba3a7 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx @@ -2,8 +2,9 @@ import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; +import { Modal } from '../../../common/Modal'; import { mermaidComponents } from '../../../common/markdownMermaid'; -import { REMOTE_REHYPE_PLUGINS } from '../../../../markdown'; +import { REMOTE_REHYPE_PLUGINS } from '../../../../lib/markdown'; import type { MatchedRule } from '../types'; export function RulePreviewModal({ @@ -15,39 +16,30 @@ export function RulePreviewModal({ }) { const { t } = useTranslation(); return ( -
-
e.stopPropagation()} - role="dialog" - aria-label={t('chatPane.rulePreviewAria')} - > -
-

{t('chatPane.rulePreviewTitle', { id: rule.id })}

- -
-
-
-
{t('chatPane.ruleFilePath')}
-
{rule.filePath}
-
priority
-
{rule.priority}
-
tools
-
{rule.tools.join(', ')}
-
-
- - {rule.instructions} - -
-
+ +
+
{t('chatPane.ruleFilePath')}
+
{rule.filePath}
+
priority
+
{rule.priority}
+
tools
+
{rule.tools.join(', ')}
-
+
+ + {rule.instructions} + +
+ ); } diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx index 1a4b173..c29fb18 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx @@ -5,7 +5,7 @@ import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import { QuestionIcon } from '../../../common/icons'; import { mermaidComponents } from '../../../common/markdownMermaid'; -import { REMOTE_REHYPE_PLUGINS } from '../../../../markdown'; +import { REMOTE_REHYPE_PLUGINS } from '../../../../lib/markdown'; import { parseAnsi, segmentStyle } from '../../../../utils/ansi'; export function Spinner() { diff --git a/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx b/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx new file mode 100644 index 0000000..e8bebbf --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next'; +import { RobotIcon, RobotOffIcon } from '../../../common/icons'; +import { StatusChip } from '../../../common/StatusChip'; + +/** + * AutoPilot 开关 chip:默认关,点击切换(持久化到 agent.autopilot.enabled,下次 poll 生效)。 + */ +export function AutopilotChip({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: () => void; +}) { + const { t } = useTranslation(); + return ( + + {enabled ? : } + {t('statusBar.autopilot')} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx b/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx new file mode 100644 index 0000000..8b430a9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx @@ -0,0 +1,210 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { invoke } from '../../../../api'; +import { useChatRunStore } from '../../../../stores/chat-run-store'; +import { formatElapsed } from '../../../../utils/time'; +import { StatusChip } from '../../../common/StatusChip'; + +/** + * 队列弹出菜单:状态栏 chip 上方弹出。先列全部运行中(active)行,再列 waiting 行。 + * waiting 行右侧 × 按钮取消。最多 6 个 item 高度,超出内部滚动。 + */ +function QueuePopover({ + active, + waiting, + onCancel, + onJumpToPr, +}: { + active: ReturnType['active']; + waiting: ReturnType['waiting']; + onCancel: (runId: string) => void; + onJumpToPr: (localId: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+ {t('statusBar.queueHeader')} + + {t('statusBar.queueSummary', { running: active.length, waiting: waiting.length })} + +
+
    + {active.map((a) => ( +
  • +
  • + ))} + {waiting.map((q) => ( +
  • +
  • + ))} +
+
+ ); +} + +/** + * pr-agent 活动状态 chip:active 时显示运行中工具 + elapsed (可点跳 PR);idle 时 + * 显示"空闲"占位。PR 切换不会丢运行中状态,由 chatRunStore 跨实例维护。 + * + * 调用方应在 pr-agent 实际可用 (PrAgentStatus.available) 时才挂这条。 + */ +export function PrAgentActiveChip({ onJumpToPr }: { onJumpToPr?: (localId: string) => void }) { + const { t } = useTranslation(); + const { active, waiting } = useChatRunStore(); + // 并发模型:active 是运行中 run 列表。chip 主体展示第一条(primary)的 tool + elapsed, + // 多于一条时用徽标显示并发总数;点开 popover 列出全部运行中 + 排队中。 + const primary = active[0] ?? null; + const runningCount = active.length; + // 计时器:1s 粒度,跟 ChatPane 的 elapsed 同步。仅有 primary 时启 + const [elapsedMs, setElapsedMs] = useState(0); + // startedAt 入队时为 null,executeRun 起跑时设值;fallback 到 enqueuedAt 即可 + const startMs = primary ? new Date(primary.startedAt ?? primary.enqueuedAt).getTime() : 0; + useEffect(() => { + if (!primary) return; + setElapsedMs(Date.now() - startMs); + const id = setInterval(() => setElapsedMs(Date.now() - startMs), 1000); + return () => clearInterval(id); + // 仅依赖 primary runId + startMs:其它字段变化不影响计时 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [primary?.runId, startMs]); + + // 队列弹出菜单:点开 (active chip + 队列 ≥1) 显示 waiting 列表 + × 取消 + const [queueOpen, setQueueOpen] = useState(false); + const queueRef = useRef(null); + useEffect(() => { + if (!queueOpen) return; + const onDown = (e: MouseEvent): void => { + if (!queueRef.current?.contains(e.target as Node)) setQueueOpen(false); + }; + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setQueueOpen(false); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [queueOpen]); + // 无可展开内容(无排队 且 运行中 ≤1)→ 自动收起菜单 + useEffect(() => { + if (queueOpen && waiting.length === 0 && active.length <= 1) setQueueOpen(false); + }, [queueOpen, waiting.length, active.length]); + + const handleCancelQueued = (runId: string): void => { + void invoke('pragent:cancel', { runId }); + }; + + if (!primary) { + // Idle:静态灰点 + "空闲" 文案。让用户一眼看到"agent 可用 + 当前没活儿" + return ( + + + ); + } + + // 可展开(运行中 >1 或有排队)→ chip 变 button 点开 popover;否则按 onJumpToPr 跳 PR。 + const expandable = waiting.length > 0 || runningCount > 1; + const clickable = expandable || Boolean(onJumpToPr); + const handleClick = (): void => { + if (expandable) { + setQueueOpen((v) => !v); + } else { + onJumpToPr?.(primary.prLocalId); + } + }; + // 徽标数 = 其它并发运行中(runningCount-1) + 排队中(waiting) + const extraCount = runningCount - 1 + waiting.length; + const title = expandable + ? t('statusBar.prAgentExpandableTitle', { running: runningCount, waiting: waiting.length }) + : t('statusBar.prAgentRunningTitle', { pr: primary.prLocalId, tool: primary.tool }) + + (clickable ? t('statusBar.jumpHint') : ''); + const inner = ( + <> +
+

+ #{pr.remoteId} {pr.title} +

+
+ {pr.hasConflict && ( + <> + + ⚠️ {t('mainPane.conflict')} + + · + + )} + + {pr.repo.projectKey}/{pr.repo.repoSlug} + + · {pr.author.displayName} + + {' '} + · {pr.sourceRef.displayId} → {pr.targetRef.displayId} + + · + {pr.localStatus} +
+
+ + {t('mainPane.openInBrowser')} + + {/* approve / needs work:当前状态 = 高亮;点已高亮的回退到 pending(撤销远端标记)。 + 「提交评论 (N)」放在决断按钮左边 — 评审动作分两步:先发评论 (左),再下决断 (右)。 */} +
+ {/* "提交评论" 仅在有待发布草稿时渲染:N=0 时整按钮隐藏,减少 header 视觉噪音。 */} + {publishableCount > 0 && ( + + )} + {/* 合并按钮:仅在服务端判定可合并 (canMerge) 时出现。点击直接合并(无二次确认)。 */} + {pr.mergeStatus?.canMerge && ( + + )} + {reviewAllowed('approved') && ( + + )} + {reviewAllowed('needsWork') && ( + + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx new file mode 100644 index 0000000..beb627e --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx @@ -0,0 +1,220 @@ +import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { LocalPrStatus, PlatformCapabilities, StoredPullRequest } from '@meebox/shared'; +import { invoke } from '../../../api'; +import { useDraftsForPr } from '../../../stores/drafts-store'; +import { PaneLoading } from '../../common/Loading'; +import { CommentsPanel } from './tabs/comments/CommentsPanel'; +import { CommitsPanel } from './tabs/CommitsPanel'; +// Monaco 编辑器(~10MB)懒加载:只有真正切到 Diff tab 才拉取 DiffView chunk, +// 不阻塞窗口首帧 / PR 列表 / 首启向导。 +const DiffView = lazy(() => import('./tabs/diff/DiffView').then((m) => ({ default: m.DiffView }))); +import { DraftsPanel } from './tabs/drafts/DraftsPanel'; +import { PrInfoView } from './tabs/PrInfoView'; +import { PublishReviewModal } from './tabs/drafts/PublishReviewModal'; +import { PrHeader } from './PrHeader'; +import { PrTabs, type PrTab } from './tabs/PrTabs'; + +export interface PrPanelProps { + pr: StoredPullRequest; + onSetStatus: (status: LocalPrStatus) => void; + onMerge: () => void; + merging?: boolean; + capabilities?: PlatformCapabilities; + currentUserName?: string | null; + pendingDiffNav?: { + runId?: string; + findingId?: string; + anchor: { path: string; startLine: number; endLine: number }; + } | null; + onDiffNavConsumed?: () => void; + onRequestDiffNav?: (target: { + runId?: string; + findingId?: string; + anchor: { path: string; startLine: number; endLine: number }; + }) => void; +} + +/** + * PR 评审工作区:头部(标题 / 动作)+ tab 栏 + tab 内容(diff / 评论 / 草稿 / 提交 / 信息)+ + * 发布评论弹窗。承载 PR 详情相关的全部状态(当前 tab / diff 视图选项 / 评论 + 提交计数 / + * 草稿池 / 发布弹窗),由 layout/MainPane 在选中 PR 时挂载。 + */ +export function PrPanel({ + pr, + onSetStatus, + onMerge, + merging = false, + capabilities, + currentUserName, + pendingDiffNav, + onDiffNavConsumed, + onRequestDiffNav, +}: PrPanelProps) { + const { t } = useTranslation(); + const [tab, setTab] = useState('diff'); + // 收到跳转请求 → 强制切到 Diff tab,DiffView 自己负责消费 anchor + useEffect(() => { + if (pendingDiffNav) setTab('diff'); + }, [pendingDiffNav]); + const [renderSideBySide, setRenderSideBySide] = useState(() => { + const v = localStorage.getItem('meebox.diffMode'); + return v === null ? true : v === 'side-by-side'; + }); + // Blame 默认关:每次启动都得手动开(blame fetch 可能慢/失败,不希望用户进来就被错误 banner 干扰) + const [showBlame, setShowBlame] = useState(false); + // 空白字符可视化:默认关(大多数 review 不关心空格 / tab;强调时再开) + const [showWhitespace, setShowWhitespace] = useState( + () => localStorage.getItem('meebox.showWhitespace') === '1', + ); + useEffect(() => { + localStorage.setItem('meebox.showWhitespace', showWhitespace ? '1' : '0'); + }, [showWhitespace]); + // 评论 / commits 数 chip:PR 切换时各拉一次,cancelled token 防 race。deps 含 pr.updatedAt: + // 远端变更后 poller 拉到 → store 更新 → 这里重跑刷新计数,app 一直开着也能跟上远端变动。 + const [commentCount, setCommentCount] = useState(null); + const [commitCount, setCommitCount] = useState(null); + const prLocalId = pr.localId; + const prUpdatedAt = pr.updatedAt; + useEffect(() => { + setCommentCount(null); + setCommitCount(null); + let cancelled = false; + void (async () => { + try { + const [cm, cc] = await Promise.all([ + // force:true 跳过 cache stale 比对 — 本地 PR.updatedAt 可能滞后于远端(poller 周期性拉)。 + invoke('diff:listComments', { localId: prLocalId, force: true }), + invoke('diff:commitCount', { localId: prLocalId }), + ]); + if (cancelled) return; + setCommentCount(cm.length); + setCommitCount(cc?.count ?? null); + } catch { + // 静默:角标不显示数字,不该挡用户视线 + } + })(); + return () => { + cancelled = true; + }; + }, [prLocalId, prUpdatedAt]); + useEffect(() => { + localStorage.setItem('meebox.diffMode', renderSideBySide ? 'side-by-side' : 'unified'); + }, [renderSideBySide]); + // 清掉历史遗留的 showBlame 持久化值;新逻辑不再读写它 + useEffect(() => { + if (localStorage.getItem('meebox.showBlame') !== null) { + localStorage.removeItem('meebox.showBlame'); + } + }, []); + + // M4 草稿池 → "提交评论 (N)" 按钮的 N。pending + edited 才算 publishable; + // rejected(用户决断不发)/ posted(远端已发)都排除 + const drafts = useDraftsForPr(prLocalId); + const publishableCount = useMemo( + () => + (drafts ?? []).reduce( + (n, d) => (d.status === 'pending' || d.status === 'edited' ? n + 1 : n), + 0, + ), + [drafts], + ); + // 草稿 tab 显示条件用总数(任何 status 都算);只有从来没创建过草稿的 PR 才完全隐藏 tab + const totalDraftCount = (drafts ?? []).length; + const [publishModalOpen, setPublishModalOpen] = useState(false); + // 兜底:停在 'drafts' tab 但草稿全清空 → 切回 'diff' 避免显示孤儿空白内容区 + useEffect(() => { + if (tab === 'drafts' && totalDraftCount === 0) setTab('diff'); + }, [tab, totalDraftCount]); + + return ( + <> + setPublishModalOpen(true)} + /> + setShowWhitespace((b) => !b)} + showBlame={showBlame} + onToggleBlame={() => setShowBlame((b) => !b)} + renderSideBySide={renderSideBySide} + onSetRenderSideBySide={setRenderSideBySide} + /> +
+ {tab === 'diff' && ( + }> + + + )} + {tab === 'comments' && ( + setCommentCount(n)} + capabilities={capabilities} + /> + )} + {tab === 'drafts' && ( + { + const d = (drafts ?? []).find((x) => x.id === draftId); + if (!d) return; + onRequestDiffNav?.({ + anchor: { + path: d.anchor.path, + startLine: d.anchor.startLine, + endLine: d.anchor.endLine, + }, + }); + }} + /> + )} + {tab === 'commits' && } + {tab === 'info' && } +
+ {publishModalOpen && ( + setPublishModalOpen(false)} + onJumpToAnchor={(draftId) => { + // 点 anchor → 关 modal + 转 pendingDiffNav 上抛给 App。runId/findingId 不带 → + // DiffView 仅 navigate 不进 edit(用户想看代码上下文,不一定是要改草稿)。 + const d = (drafts ?? []).find((x) => x.id === draftId); + if (!d) return; + setPublishModalOpen(false); + onRequestDiffNav?.({ + anchor: { + path: d.anchor.path, + startLine: d.anchor.startLine, + endLine: d.anchor.endLine, + }, + }); + }} + /> + )} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts b/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts new file mode 100644 index 0000000..f808448 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts @@ -0,0 +1,91 @@ +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { LocalPrStatus, StoredPullRequest } from '@meebox/shared'; +import { invoke } from '../../../../api'; + +/** + * PR 列表生命周期与详情动作(领域内聚):列表 state + 选中态、读缓存 reload / 拉远端 refresh、 + * 审批状态决断、合并。不感知 boot/连接(选中连接的反查由 App 持 boot 派生;启动 / 焦点刷新由 + * useBootstrap 经 reloadPrs 驱动),仅依赖 notifyError 弹操作级错误。 + */ +export function usePullRequests({ notifyError }: { notifyError: (msg: string) => void }) { + const { t } = useTranslation(); + const [prs, setPrs] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [refreshing, setRefreshing] = useState(false); + // 合并进行中:GitHub 合并可能较慢(异步算 mergeable),按钮置等待态并防重复点击。 + const [merging, setMerging] = useState(false); + + const reloadPrs = useCallback(async (): Promise => { + const fresh = await invoke('prs:list', undefined); + setPrs(fresh); + }, []); + + const triggerRefresh = useCallback(async (): Promise => { + if (refreshing) return; + setRefreshing(true); + try { + await invoke('prs:refresh', undefined); + await reloadPrs(); + } catch (e) { + console.error('refresh failed', e); + } finally { + setRefreshing(false); + } + }, [refreshing, reloadPrs]); + + const selected = prs.find((p) => p.localId === selectedId) ?? null; + + const setSelectedPrStatus = useCallback( + async (status: LocalPrStatus): Promise => { + if (!selected) return; + try { + const updated = await invoke('prs:setLocalStatus', { localId: selected.localId, status }); + if (updated) { + setPrs((prev) => prev.map((p) => (p.localId === updated.localId ? updated : p))); + } + } catch (e) { + // 远端拒绝(如 PR 已关闭 / 合并 / 权限不足)→ 本地状态不变,弹 toast 提示。 + // 顺手刷新一次:PR 若已关闭,下一轮 poll 会把它软删,列表自洽 + const msg = e instanceof Error ? e.message : String(e); + notifyError(t('app.approveActionFailed', { msg })); + void triggerRefresh(); + } + }, + [selected, notifyError, triggerRefresh, t], + ); + + const mergeSelectedPr = useCallback(async (): Promise => { + if (!selected || merging) return; + const mergedId = selected.localId; + setMerging(true); + try { + await invoke('prs:merge', { localId: mergedId }); + } catch (e) { + // 合并失败(冲突 / veto / 权限 / PR 已关闭)→ 弹 toast,本地不变 + const msg = e instanceof Error ? e.message : String(e); + notifyError(t('app.mergeFailed', { msg })); + void triggerRefresh(); + return; + } finally { + setMerging(false); + } + // 合并成功:PR 已转 MERGED,会从 pending 列表退场。取消选中 + 刷新让其消失 + if (selectedId === mergedId) setSelectedId(null); + await triggerRefresh(); + }, [selected, selectedId, triggerRefresh, notifyError, merging, t]); + + return { + prs, + setPrs, + selectedId, + setSelectedId, + selected, + refreshing, + merging, + reloadPrs, + triggerRefresh, + setSelectedPrStatus, + mergeSelectedPr, + }; +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/index.ts b/apps/desktop/src/renderer/src/components/features/pr/index.ts new file mode 100644 index 0000000..5dd4500 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/index.ts @@ -0,0 +1,6 @@ +// features/pr 对外公共 API。内部模块(PrHeader / PrTabs / tabs/* 等)相互引用走相对路径, +// 不经此 barrel,避免循环依赖。状态栏 chip 走 features/pr/statusbar/* 子路径,不并入此处。 +export { PrPanel } from './PrPanel'; +export { PrEmpty } from './PrEmpty'; +export { PrItem } from './PrItem'; +export { usePullRequests } from './hooks/usePullRequests'; diff --git a/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx b/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx new file mode 100644 index 0000000..73a9ef0 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SyncIcon } from '../../../common/icons'; +import { StatusChip } from '../../../common/StatusChip'; +import { formatRelative } from '../../../../utils/time'; + +/** + * 刷新按钮 + 同步状态合并:一个可点击 chip,显示最近同步相对时间 + 同步图标 + * (刷新中旋转),点击触发一次轮询。 + */ +export function LastSyncChip({ + at, + refreshing, + onRefresh, +}: { + at: string | null; + refreshing: boolean; + onRefresh: () => void; +}) { + const { t } = useTranslation(); + // 每 30s 重渲染一次,让 "刚刚 / N 分钟前" 文案随时间向前推进 + const [, tick] = useState(0); + useEffect(() => { + const id = setInterval(() => tick((n) => n + 1), 30_000); + return () => clearInterval(id); + }, []); + const date = at ? new Date(at) : null; + const label = refreshing ? t('statusBar.refreshing') : date ? formatRelative(date, t) : '—'; + const title = refreshing + ? t('statusBar.refreshing') + : date + ? t('statusBar.lastSyncTitle', { time: date.toLocaleString() }) + : t('statusBar.neverSyncedTitle'); + return ( + + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx b/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx new file mode 100644 index 0000000..3258852 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from 'react-i18next'; +import { PullRequestIcon } from '../../../common/icons'; +import { StatusChip } from '../../../common/StatusChip'; + +/** 待处理 PR 计数 chip。 */ +export function PrsCountChip({ count }: { count: number }) { + const { t } = useTranslation(); + return ( + + + {count} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/statusbar/RepoSyncChip.tsx b/apps/desktop/src/renderer/src/components/features/pr/statusbar/RepoSyncChip.tsx new file mode 100644 index 0000000..d0b65ef --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/statusbar/RepoSyncChip.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next'; +import { useRepoSyncStore } from '../../../../stores/repo-sync-store'; +import { StatusChip } from '../../../common/StatusChip'; + +/** + * Repo sync 活动 chip:显示当前正在 clone/fetch 的 repo + 阶段 + 百分比。 + * 队列里只有一条在跑 (RepoMirrorManager 全局单队列);store 收着多条时只展示首条。 + * idle 不渲染,避免占状态栏宽度。 + */ +export function RepoSyncChip() { + const { t } = useTranslation(); + const { active } = useRepoSyncStore(); + if (active.size === 0) return null; + // Map 没保证迭代序,但 sync 同时只跑一个,多于一个时按 startedAt 升序选最早的 + const snapshots = Array.from(active.values()).sort((a, b) => a.startedAt - b.startedAt); + const cur = snapshots[0]!; + const more = snapshots.length - 1; + // repo = "host/projectKey/repoSlug",UI 紧凑只展示最后一段 + const shortRepo = cur.repo.split('/').slice(-1)[0] ?? cur.repo; + const stageLabel = cur.stage ? `${cur.stage}` : t('statusBar.syncing'); + const pct = typeof cur.percent === 'number' ? ` ${String(Math.round(cur.percent))}%` : ''; + const queueSuffix = more > 0 ? ` (+${String(more)})` : ''; + return ( + + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/CommitsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx similarity index 97% rename from apps/desktop/src/renderer/src/components/features/pr/CommitsPanel.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx index 4fc66a4..3367a58 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/CommitsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import type { PrCommit, StoredPullRequest } from '@meebox/shared'; -import { invoke } from '../../../api'; -import { formatBackendError, type FormattedError } from '../../../errors'; -import { Avatar } from '../../common/Avatar'; +import { invoke } from '../../../../api'; +import { formatBackendError, type FormattedError } from '../../../../errors'; +import { Avatar } from '../../../common/Avatar'; interface CommitsPanelProps { pr: StoredPullRequest; diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrInfoView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx similarity index 94% rename from apps/desktop/src/renderer/src/components/features/pr/PrInfoView.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx index 13628a5..f847987 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrInfoView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx @@ -2,9 +2,9 @@ import { useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import type { ReviewerStatus, StoredPullRequest } from '@meebox/shared'; -import { REMOTE_REHYPE_PLUGINS } from '../../../markdown'; -import { makeBitbucketImageFor, transformBitbucketUrl } from '../../common/BitbucketImage'; -import { mermaidComponents } from '../../common/markdownMermaid'; +import { REMOTE_REHYPE_PLUGINS } from '../../../../lib/markdown'; +import { makeBitbucketImageFor, transformBitbucketUrl } from '../../../common/BitbucketImage'; +import { mermaidComponents } from '../../../common/markdownMermaid'; interface PrInfoViewProps { pr: StoredPullRequest; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx new file mode 100644 index 0000000..2a2fe30 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx @@ -0,0 +1,159 @@ +import { useTranslation } from 'react-i18next'; +import { PersonIcon, WhitespaceIcon } from '../../../common/icons'; + +export type PrTab = 'diff' | 'comments' | 'drafts' | 'commits' | 'info'; + +/** + * PR 详情 tab 栏:diff / 评论 / 草稿 / 提交 / 信息(带计数徽标),diff tab 时右侧附带 + * 空白可视 / blame / 并排-内联 切换工具条。 + */ +export function PrTabs({ + tab, + onTab, + commentCount, + commitCount, + totalDraftCount, + publishableCount, + showWhitespace, + onToggleWhitespace, + showBlame, + onToggleBlame, + renderSideBySide, + onSetRenderSideBySide, +}: { + tab: PrTab; + onTab: (tab: PrTab) => void; + commentCount: number | null; + commitCount: number | null; + totalDraftCount: number; + publishableCount: number; + showWhitespace: boolean; + onToggleWhitespace: () => void; + showBlame: boolean; + onToggleBlame: () => void; + renderSideBySide: boolean; + onSetRenderSideBySide: (next: boolean) => void; +}) { + const { t } = useTranslation(); + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/comments/CommentEditEditor.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentEditEditor.tsx similarity index 98% rename from apps/desktop/src/renderer/src/components/features/comments/CommentEditEditor.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentEditEditor.tsx index 2b575ef..8f764cc 100644 --- a/apps/desktop/src/renderer/src/components/features/comments/CommentEditEditor.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentEditEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { invoke } from '../../../api'; +import { invoke } from '../../../../../api'; interface CommentEditEditorProps { prLocalId: string; diff --git a/apps/desktop/src/renderer/src/components/features/comments/CommentReplyEditor.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx similarity index 98% rename from apps/desktop/src/renderer/src/components/features/comments/CommentReplyEditor.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx index a2588fd..d854539 100644 --- a/apps/desktop/src/renderer/src/components/features/comments/CommentReplyEditor.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { invoke } from '../../../api'; +import { invoke } from '../../../../../api'; interface CommentReplyEditorProps { prLocalId: string; diff --git a/apps/desktop/src/renderer/src/components/features/comments/CommentsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentsPanel.tsx similarity index 97% rename from apps/desktop/src/renderer/src/components/features/comments/CommentsPanel.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentsPanel.tsx index 5202525..4680294 100644 --- a/apps/desktop/src/renderer/src/components/features/comments/CommentsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentsPanel.tsx @@ -4,16 +4,16 @@ import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import type { PlatformCapabilities, PrComment, StoredPullRequest } from '@meebox/shared'; -import { invoke, subscribe } from '../../../api'; -import i18n from '../../../i18n'; -import { formatBackendError, type FormattedError } from '../../../errors'; -import { REMOTE_REHYPE_PLUGINS } from '../../../markdown'; -import { Avatar } from '../../common/Avatar'; -import { makeBitbucketImageFor, transformBitbucketUrl } from '../../common/BitbucketImage'; +import { invoke, subscribe } from '../../../../../api'; +import i18n from '../../../../../i18n'; +import { formatBackendError, type FormattedError } from '../../../../../errors'; +import { REMOTE_REHYPE_PLUGINS } from '../../../../../lib/markdown'; +import { Avatar } from '../../../../common/Avatar'; +import { makeBitbucketImageFor, transformBitbucketUrl } from '../../../../common/BitbucketImage'; import { CommentEditEditor } from './CommentEditEditor'; import { CommentReplyEditor } from './CommentReplyEditor'; -import { ConfirmModal } from '../../common/ConfirmModal'; -import { mermaidComponents } from '../../common/markdownMermaid'; +import { ConfirmModal } from '../../../../common/ConfirmModal'; +import { mermaidComponents } from '../../../../common/markdownMermaid'; // 行内代码上下文用 Monaco,懒加载随 DiffView 同一套 Monaco chunk 按需拉取,不进入口包。 const InlineCodeContext = lazy(() => import('./InlineCodeContext').then((m) => ({ default: m.InlineCodeContext })), diff --git a/apps/desktop/src/renderer/src/components/features/comments/InlineCodeContext.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx similarity index 96% rename from apps/desktop/src/renderer/src/components/features/comments/InlineCodeContext.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx index b68d854..40213a3 100644 --- a/apps/desktop/src/renderer/src/components/features/comments/InlineCodeContext.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx @@ -1,14 +1,14 @@ // 必须在用到 @monaco-editor/react 之前执行(见 DiffView 同款说明)。本文件经 // React.lazy 动态加载 → Monaco 随本 chunk 按需拉取,不进入口包。 -import '../../../monaco-setup'; +import '../../../../../lib/monaco-setup'; import { Editor, type Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import { memo, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { PrCommentAnchor, StoredPullRequest } from '@meebox/shared'; -import { invoke } from '../../../api'; -import { editorFontSize } from '../../../editor-font'; -import { languageFor } from '../../../utils/language'; +import { invoke } from '../../../../../api'; +import { editorFontSize } from '../../../../../lib/editor-font'; +import { languageFor } from '../../../../../utils/language'; interface InlineCodeContextProps { pr: StoredPullRequest; diff --git a/apps/desktop/src/renderer/src/components/features/diff/DiffSearchPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/diff/DiffSearchPanel.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx index eca54e8..befac9c 100644 --- a/apps/desktop/src/renderer/src/components/features/diff/DiffSearchPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx @@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { editor as MonacoEditorNs } from 'monaco-editor'; import type { DiffChangedFile } from '@meebox/shared'; -import { invoke } from '../../../api'; -import { languageFor } from '../../../utils/language'; +import { invoke } from '../../../../../api'; +import { languageFor } from '../../../../../utils/language'; /** * 搜索 PR diff 全部变更文件内容。仿 Bitbucket "Search code" 入口的行为: diff --git a/apps/desktop/src/renderer/src/components/features/diff/DiffView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/diff/DiffView.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx index 57b8314..e31bbcb 100644 --- a/apps/desktop/src/renderer/src/components/features/diff/DiffView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx @@ -1,6 +1,6 @@ // 必须在用到 @monaco-editor/react 之前执行(loader.config 指向本地 monaco)。 // 本文件经 React.lazy 动态加载,故 Monaco 随本 chunk 按需拉取,不进入口包。 -import '../../../monaco-setup'; +import '../../../../../lib/monaco-setup'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createRoot, type Root } from 'react-dom/client'; @@ -21,23 +21,23 @@ import type { SyncProgressEvent, } from '@meebox/shared'; import { policyForPlatform } from '@meebox/shared'; -import { invoke, subscribe } from '../../../api'; -import { editorFontSize } from '../../../editor-font'; -import { formatBackendError, type FormattedError } from '../../../errors'; -import { REMOTE_REHYPE_PLUGINS } from '../../../markdown'; -import { useDraftsForPr } from '../../../stores/drafts-store'; -import { Avatar } from '../../common/Avatar'; +import { invoke, subscribe } from '../../../../../api'; +import { editorFontSize } from '../../../../../lib/editor-font'; +import { formatBackendError, type FormattedError } from '../../../../../errors'; +import { REMOTE_REHYPE_PLUGINS } from '../../../../../lib/markdown'; +import { useDraftsForPr } from '../../../../../stores/drafts-store'; +import { Avatar } from '../../../../common/Avatar'; import { DraftZone } from '../drafts/DraftZone'; -import { ErrorBoundary } from '../../common/ErrorBoundary'; -import { makeBitbucketImageFor, transformBitbucketUrl } from '../../common/BitbucketImage'; +import { ErrorBoundary } from '../../../../common/ErrorBoundary'; +import { makeBitbucketImageFor, transformBitbucketUrl } from '../../../../common/BitbucketImage'; import { CommentEditEditor } from '../comments/CommentEditEditor'; import { CommentReplyEditor } from '../comments/CommentReplyEditor'; -import { ConfirmModal } from '../../common/ConfirmModal'; +import { ConfirmModal } from '../../../../common/ConfirmModal'; import { DiffSearchPanel } from './DiffSearchPanel'; import { FileTree } from './FileTree'; -import { PaneLoading } from '../../common/Loading'; -import { FileTreeIcon, SearchIcon } from '../../common/icons'; -import { languageFor } from '../../../utils/language'; +import { PaneLoading } from '../../../../common/Loading'; +import { FileTreeIcon, SearchIcon } from '../../../../common/icons'; +import { languageFor } from '../../../../../utils/language'; interface DiffViewProps { pr: StoredPullRequest; diff --git a/apps/desktop/src/renderer/src/components/features/diff/FileTree.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/diff/FileTree.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx index acd2bc3..eaa157b 100644 --- a/apps/desktop/src/renderer/src/components/features/diff/FileTree.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { Icon } from '@iconify/react'; import type { DiffChangedFile } from '@meebox/shared'; -import { ChevronIcon } from '../../common/icons'; +import { ChevronIcon } from '../../../../common/icons'; interface FileTreeProps { files: DiffChangedFile[]; diff --git a/apps/desktop/src/renderer/src/components/features/drafts/DraftZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/drafts/DraftZone.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx index a4bfade..ff41c8f 100644 --- a/apps/desktop/src/renderer/src/components/features/drafts/DraftZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx @@ -4,8 +4,8 @@ import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import type { ReviewDraft } from '@meebox/shared'; -import { ConfirmModal } from '../../common/ConfirmModal'; -import { TrashIcon } from '../../common/icons'; +import { ConfirmModal } from '../../../../common/ConfirmModal'; +import { TrashIcon } from '../../../../common/icons'; interface DraftZoneProps { draft: ReviewDraft; diff --git a/apps/desktop/src/renderer/src/components/features/drafts/DraftsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftsPanel.tsx similarity index 98% rename from apps/desktop/src/renderer/src/components/features/drafts/DraftsPanel.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftsPanel.tsx index f485fcb..8a15424 100644 --- a/apps/desktop/src/renderer/src/components/features/drafts/DraftsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftsPanel.tsx @@ -4,9 +4,9 @@ import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import type { PlatformCapabilities, ReviewDraft, StoredPullRequest } from '@meebox/shared'; -import { invoke } from '../../../api'; -import { useDraftsForPr } from '../../../stores/drafts-store'; -import { ConfirmModal } from '../../common/ConfirmModal'; +import { invoke } from '../../../../../api'; +import { useDraftsForPr } from '../../../../../stores/drafts-store'; +import { ConfirmModal } from '../../../../common/ConfirmModal'; // posted 已不存在 (发布成功即删本地),筛选项只保留 publishable / all / rejected type Filter = 'all' | 'publishable' | 'rejected'; diff --git a/apps/desktop/src/renderer/src/components/features/drafts/PublishReviewModal.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx similarity index 99% rename from apps/desktop/src/renderer/src/components/features/drafts/PublishReviewModal.tsx rename to apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx index f9a6440..d729937 100644 --- a/apps/desktop/src/renderer/src/components/features/drafts/PublishReviewModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { ReviewDraft } from '@meebox/shared'; -import { invoke } from '../../../api'; +import { invoke } from '../../../../../api'; /** * 批量发布草稿到 Bitbucket 的确认 modal。M4 发布闭环最后一公里。 diff --git a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx index f9c50fc..a7521f8 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx @@ -1,38 +1,20 @@ -import { useEffect, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; -import { - LANGUAGE_OPTIONS, - type AppInfo, - type AppPaths, - type Config, - type LlmProfile, - type SupportedLanguage, - type UpdateCheckResult, -} from '@meebox/shared'; -import { invoke } from '../../../api'; -import i18n, { persistLanguage, resolveUiLanguage } from '../../../i18n'; +import type { AppInfo, AppPaths, Config, SupportedLanguage } from '@meebox/shared'; import { ConfirmModal } from '../../common/ConfirmModal'; -import { - ConnectionForm, - connDraftCanSave, - fromConnDraft, - toConnDraft, - type ConnDraft, -} from './ConnectionForm'; -import { LlmProfileForm, newProfileId, providerLabel, validateProfile } from './LlmProfileForm'; -import { - CloseIcon, - EyeIcon, - EyeOffIcon, - FolderIcon, - GitHubMarkIcon, - IssueIcon, - PencilIcon, - TagIcon, - TrashIcon, -} from '../../common/icons'; -import { LlmProviderIcon } from '../../common/LlmProviderIcon'; -import { PLATFORM_META } from '../../common/PlatformIcon'; +import { Modal } from '../../common/Modal'; +import { useSettingsDraft } from './hooks/useSettingsDraft'; +import { ConnectionEditorModal } from './editors/ConnectionEditorModal'; +import { LlmEditorModal } from './editors/LlmEditorModal'; +import { ProxyEditorModal } from './editors/ProxyEditorModal'; +import { LanguageSection } from './sections/LanguageSection'; +import { ConnectionsSection } from './sections/ConnectionsSection'; +import { PollerSection } from './sections/PollerSection'; +import { LlmSection } from './sections/LlmSection'; +import { ProxySection } from './sections/ProxySection'; +import { AgentDirSection } from './sections/AgentDirSection'; +import { WorkDirSection } from './sections/WorkDirSection'; +import { CacheDirSection } from './sections/CacheDirSection'; +import { RuntimeSection } from './sections/RuntimeSection'; interface SettingsModalProps { info: AppInfo; @@ -52,18 +34,10 @@ interface SettingsModalProps { onClose: () => void; } -// 轮询间隔档位(秒):低值细(30s 一档)、高值粗(分钟级),梯度放大。滑块拖的是 -// 档位索引而非秒数,从而实现非线性步长 + 离散刻度。 -const POLLER_TIERS = [60, 90, 120, 180, 300, 600, 900]; -/** 取最接近给定秒数的档位索引(配置值不在档位上时就近吸附) */ -function nearestPollerIdx(seconds: number): number { - let best = 0; - for (let i = 1; i < POLLER_TIERS.length; i++) { - if (Math.abs(POLLER_TIERS[i]! - seconds) < Math.abs(POLLER_TIERS[best]! - seconds)) best = i; - } - return best; -} - +/** + * 设置面板(容器):布局编排 + 装配各分区。草稿 / 保存状态机归 useSettingsDraft, + * 各设置分区拆到 sections/,连接 / 代理 / LLM 编辑器拆到 editors/,通用模态壳用 common/Modal。 + */ export function SettingsModal({ info, paths, @@ -75,1148 +49,127 @@ export function SettingsModal({ onClose, }: SettingsModalProps) { const { t } = useTranslation(); - const [opening, setOpening] = useState(false); - const [openError, setOpenError] = useState(null); - - // 草稿 → 整体保存:所有编辑只改本地 state,点底栏"保存"才整体写盘 + 生效 - const [reposDirInput, setReposDirInput] = useState(config.workspace.repos_dir); - // Agent 其余字段(max_steps / summary_max_chars / autopilot)在 UI 不编辑,仅持有以便保存时 - // 原样回传、不被覆盖成默认值;只有目录经 agentDirInput 可编辑。 - const [agent] = useState(config.agent); - const [agentDirInput, setAgentDirInput] = useState(config.agent.dir); - const [pollerInput, setPollerInput] = useState(String(config.poller.interval_seconds)); - const [llm, setLlm] = useState(config.llm); - const [llmEditor, setLlmEditor] = useState<{ mode: 'add' | 'edit'; draft: LlmProfile } | null>( - null, - ); - const [proxy, setProxy] = useState(config.proxy); - // 代理在独立模态框里编辑:null=关闭,非 null=正在编辑的草稿;保存回 proxy,底栏「保存」才写盘。 - const [proxyEditor, setProxyEditor] = useState(null); - - // 保存基线:保存成功后更新,用于 changed 判定(禁用保存按钮) - const [base, setBase] = useState(() => ({ - reposDir: config.workspace.repos_dir, - agentDir: config.agent.dir, - poller: config.poller.interval_seconds, - llm: config.llm, - proxy: config.proxy, - connections: config.connections, - activeConnId: config.active_connection_id, - })); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [saveError, setSaveError] = useState(null); - - // UI 语言:即时生效项(不走全局保存)。下拉值取「当前生效语言」(config.language 经 - // resolveUiLanguage 解析,空配置 → OS 偏好)。选择后立即写盘 + 主进程/渲染层同步切换。 - const [language, setLanguage] = useState(() => - resolveUiLanguage(config.language), - ); - const handleLanguageChange = (next: SupportedLanguage): void => { - if (next === language) return; - setLanguage(next); - void i18n.changeLanguage(next); // 渲染层实时切换 - persistLanguage(next); // localStorage 缓存,下次启动同步命中 - onLanguageChange?.(next); // 同步父级 boot.config.language - invoke('config:setLanguage', { language: next }).catch((e: unknown) => { - // 写盘 / 主进程切换失败不回滚 UI(已切),仅提示;下次启动按 localStorage 兜底 - setSaveError(e instanceof Error ? e.message : String(e)); - }); - }; - - const [totalBytes, setTotalBytes] = useState(null); - - useEffect(() => { - invoke('repo:getTotalSize', undefined) - .then((r) => setTotalBytes(r.totalBytes)) - .catch(() => setTotalBytes(0)); - }, []); - - const openConfigFile = async (): Promise => { - setOpening(true); - setOpenError(null); - try { - await invoke('app:openConfigFile', undefined); - } catch (e) { - setOpenError(e instanceof Error ? e.message : String(e)); - } finally { - setOpening(false); - } - }; - - const pollerIdx = nearestPollerIdx(Number.parseInt(pollerInput, 10) || 300); - const pollerFillPct = (pollerIdx / (POLLER_TIERS.length - 1)) * 100; - - // 连接 / LLM 编辑:改本地 state + 自动写入 config.yaml(防丢失),但不应用到运行时 - //(不 reconfigure;重启或点底栏「保存」才生效)。其余配置(规则/轮询/缓存)仍纯草稿。 - const autosaveDraft = ( - nextConnections: Config['connections'], - activeId: string, - nextLlm: Config['llm'], - ): void => { - void invoke('config:autosaveDraft', { - connections: nextConnections, - active_connection_id: activeId, - llm: nextLlm, - }).catch(() => { - /* 自动保存失败不打断编辑;点底栏保存时会再写一次 */ - }); - }; - const persistLlm = (next: Config['llm']): void => { - setLlm(next); - setSaved(false); - // 提升到 App 的 boot.config.llm:重开模态框时 SettingsModal 用最新 prop 重建本地 - // state,新增/编辑的渠道不丢。onLlmChange 只改渲染层 state、不调 config:setLlm, - // 所以是"写入(磁盘+渲染层)但不启用"——运行时仍用旧 active 模型,直到底栏「保存」 - // 走 config:setLlm 或显式切换启用渠道才真正应用。 - onLlmChange?.(next); - autosaveDraft(connections, activeConnId, next); - }; - const openAddProfile = (): void => { - setLlmEditor({ - mode: 'add', - draft: { - id: newProfileId(), - label: '', - provider: 'openai-compatible', - base_url: '', - model: '', - api_key: '', - }, - }); - }; - const openEditProfile = (id: string): void => { - const p = llm.profiles.find((x) => x.id === id); - if (!p) return; - setLlmEditor({ mode: 'edit', draft: { ...p } }); - }; - const closeEditor = (): void => setLlmEditor(null); - const saveEditor = async (): Promise => { - if (!llmEditor) return; - const { mode, draft } = llmEditor; - const profiles = - mode === 'add' - ? [...llm.profiles, draft] - : llm.profiles.map((p) => (p.id === draft.id ? draft : p)); - const active_id = mode === 'add' && !llm.active_id ? draft.id : llm.active_id; - await persistLlm({ profiles, active_id }); - setLlmEditor(null); - }; - const deleteProfile = async (id: string): Promise => { - const profiles = llm.profiles.filter((p) => p.id !== id); - const active_id = llm.active_id === id ? (profiles[0]?.id ?? '') : llm.active_id; - await persistLlm({ profiles, active_id }); - }; - const setActive = async (id: string): Promise => { - if (llm.active_id === id) return; - await persistLlm({ ...llm, active_id: id }); - }; - - // ── 连接:多条可配置 + 单选启用;编辑只改本地 state,整体保存才写盘 + 热重建 ── - const [connections, setConnections] = useState(config.connections); - const [activeConnId, setActiveConnId] = useState(config.active_connection_id); - const [connEditor, setConnEditor] = useState<{ mode: 'add' | 'edit'; draft: ConnDraft } | null>( - null, - ); - const [connDeleteId, setConnDeleteId] = useState(null); - - const persistConnections = (next: Config['connections'], activeId: string): void => { - setConnections(next); - setActiveConnId(activeId); - setSaved(false); - autosaveDraft(next, activeId, llm); - }; - const openAddConn = (): void => { - setConnEditor({ - mode: 'add', - draft: { - id: newProfileId(), - kind: 'github', - display_name: '', - base_url: '', - token: '', - protocol: 'pat', - }, - }); - }; - const openEditConn = (id: string): void => { - const c = connections.find((x) => x.id === id); - if (c) setConnEditor({ mode: 'edit', draft: toConnDraft(c) }); - }; - const saveConnEditor = async (): Promise => { - if (!connEditor) return; - const { mode, draft } = connEditor; - const conn = fromConnDraft(draft); - const next = - mode === 'add' - ? [...connections, conn] - : connections.map((c) => (c.id === conn.id ? conn : c)); - // 新增首条自动设为启用 - const activeId = mode === 'add' && !activeConnId ? conn.id : activeConnId; - await persistConnections(next, activeId); - setConnEditor(null); - }; - const deleteConn = async (id: string): Promise => { - const next = connections.filter((c) => c.id !== id); - // 删的是当前启用 → 启用回退到剩下第一条(无则空串,不轮询任何连接) - const activeId = activeConnId === id ? (next[0]?.id ?? '') : activeConnId; - await persistConnections(next, activeId); - }; - const setActiveConn = (id: string): void => { - if (activeConnId === id) return; - persistConnections(connections, id); - }; - - // ── 变更检测(对比基线)+ 整体保存(仅写有变更的部分,全成功后更新基线)── - const reposDirChanged = reposDirInput.trim() !== base.reposDir; - const agentChanged = agentDirInput.trim() !== base.agentDir; - const pollerChanged = pollerInput.trim() !== String(base.poller); - const llmChanged = JSON.stringify(llm) !== JSON.stringify(base.llm); - const proxyChanged = JSON.stringify(proxy) !== JSON.stringify(base.proxy); - const connectionsChanged = - activeConnId !== base.activeConnId || - JSON.stringify(connections) !== JSON.stringify(base.connections); - const anyChanged = - reposDirChanged || - agentChanged || - pollerChanged || - llmChanged || - proxyChanged || - connectionsChanged; - - const saveAll = async (): Promise => { - if (saving || !anyChanged) return; - setSaving(true); - setSaveError(null); - setSaved(false); - try { - if (pollerChanged) { - const n = Number.parseInt(pollerInput, 10); - if (!Number.isFinite(n) || n < 60 || n > 900) - throw new Error(t('settings.pollerRangeError')); - await invoke('config:setPoller', { interval_seconds: n }); - } - if (agentChanged) { - // 仅 UI 编辑 dir;其余字段(max_steps / summary_max_chars / autopilot)从已加载的 - // config 原样保留,避免被覆盖成默认值。 - await invoke('config:setAgent', { - agent: { ...agent, dir: agentDirInput.trim() }, - }); - } - if (llmChanged) { - await invoke('config:setLlm', { llm }); - onLlmChange?.(llm); - } - if (proxyChanged) { - await invoke('config:setProxy', { proxy }); - // 同步到 App 的 boot.config.proxy:重开设置面板时 SettingsModal 用最新 prop - // 重建本地 state,才能正确回显已保存的代理配置(否则读到启动时的旧值)。 - onProxyChange?.(proxy); - } - if (connectionsChanged) { - await invoke('config:setConnections', { connections, active_connection_id: activeConnId }); - await onConnectionsChange?.(); - } - if (reposDirChanged && reposDirInput.trim()) { - await invoke('config:setReposDir', { reposDir: reposDirInput.trim() }); - } - setBase({ - reposDir: reposDirInput.trim(), - agentDir: agentDirInput.trim(), - poller: Number.parseInt(pollerInput, 10), - llm, - proxy, - connections, - activeConnId, - }); - setSaved(true); - // 保存成功后自动关闭设置面板(失败则保持打开并展示 saveError) - onClose(); - } catch (e) { - setSaveError(e instanceof Error ? e.message : String(e)); - } finally { - setSaving(false); - } - }; + const s = useSettingsDraft({ + config, + paths, + onLlmChange, + onProxyChange, + onLanguageChange, + onConnectionsChange, + onClose, + }); return ( -
-
e.stopPropagation()} role="dialog"> -
-

{t('settings.title')}

- -
-
- {/* 界面语言:即时生效项,放在最前。固定宽度下拉靠右(标题两端对齐);选项用各 - 语言自身的 endonym,不随 UI 语言翻译。 */} -
-
-

{t('settings.languageTitle')}

- -
-

- {t('settings.languageHint')} -

-
- -
-
-

{t('settings.connectionsTitle')}

- -
-

- {t('settings.connectionsHint')} -

- {connections.length === 0 ? ( -

{t('settings.connectionsEmpty')}

- ) : ( -
- {connections.map((c) => { - const isActive = c.id === activeConnId; - const platformMeta = PLATFORM_META.find((m) => m.kind === c.kind); - return ( -
- - {platformMeta && ( - - - - )} -
-
- {c.display_name || c.id} -
-
- {c.base_url} · clone via {c.clone.protocol === 'ssh' ? 'SSH' : 'PAT'} -
-
- - -
- ); - })} -
- )} -
- -
-

{t('settings.pollerTitle')}

-

- {t('settings.pollerHint')} -

-
-
- { - const idx = Number.parseInt(e.target.value, 10); - setPollerInput(String(POLLER_TIERS[idx])); - setSaved(false); - }} - aria-label={t('settings.pollerSliderAria')} - /> - {/* 档位刻度:按 thumb 实际停靠位置绝对定位(thumb 宽 12px,两端内缩 6px), - translateX(-50%) 居中对齐;当前档位高亮 */} -
- {POLLER_TIERS.map((t, i) => { - const frac = i / (POLLER_TIERS.length - 1); - return ( - - {t} - - ); - })} -
-
- - {t('settings.pollerSeconds', { n: pollerInput })} - -
-
- -
-
-

{t('settings.llmTitle')}

- -
-

- {t('settings.llmHint')} -

- {llm.profiles.length === 0 ? ( -

{t('settings.llmEmpty')}

- ) : ( -
- {llm.profiles.map((p) => { - const isActive = p.id === llm.active_id; - const titleText = - p.label || t('settings.llmProfileFallback', { id: p.id.slice(0, 4) }); - const isCli = p.provider === 'cli'; - return ( -
- - - - -
-
- {titleText} - {isCli && ( - - {t('settings.experimental')} - - )} -
-
- {providerLabel(p.provider)} - {p.model ? ` · ${p.model}` : ''} -
-
- - -
- ); - })} -
- )} -
- -
-
-
-

{t('settings.proxyTitle')}

- {/* 启用状态用 chip 表达(绿=已启用/灰=未启用),与应用其它状态视觉一致; - 地址不在此展示,详情见「配置」弹窗。 */} - - {proxy.enabled && proxy.host - ? t('settings.proxyEnabledStatus') - : t('settings.proxyDisabledStatus')} - -
- -
-

- {t('settings.proxyStatusHint')} -

-
- -
- {/* 标题行:左侧标题 + 右侧蓝色「打开当前目录」按钮(在系统文件管理器打开生效的 Agent 目录, - 便于直接查看 / 编辑文件)。放在标题行而非配置行,避免与下方的目录选择按钮混淆。 */} -
-

{t('settings.agentDirTitle')}

- {/* 文案按钮(非图标):与下方的目录「选择」图标按钮区分开,避免混淆。尺寸与其它区块标题行 - 的操作按钮(添加连接 / 添加配置 / 代理配置)一致,统一用 btn-sm。 */} - -
-

- {t('settings.agentDirHint')} -

-
- { - setAgentDirInput(e.target.value); - setSaved(false); - }} - placeholder={t('settings.agentDirPlaceholder')} - /> + <> + +
-
- -
-

{t('settings.workDirTitle')}

-
-
{t('settings.appRoot')}
-
{paths.appDir}
-
{t('settings.configKey')}
-
{paths.configFile}
-
-
- -
-

{t('settings.cacheDirTitle')}

-

- {t('settings.cacheDirHint')} -

-
-
{t('settings.currentDir')}
-
{paths.reposDir}
-
{t('settings.cacheUsage')}
-
- {totalBytes === null ? t('settings.calculating') : formatBytes(totalBytes)} -
-
-
- { - setReposDirInput(e.target.value); - setSaved(false); - }} - placeholder="~/.code-meeseeks/repos" - /> +
+ {(s.saveError ?? s.openError) && ( + {s.saveError ?? s.openError} + )} + {s.saved && !s.anyChanged && {t('settings.saved')}}
-

{t('settings.cacheDirRestartNote')}

-
+ + } + > + + + + + s.setProxyEditor(s.proxy)} /> + void s.pickAgentDir()} + /> + + void s.pickReposDir()} + totalBytes={s.totalBytes} + /> + + -
-

{t('settings.runtimeTitle')}

-
-
{t('settings.appVersion')}
-
{info.appVersion}
-
Electron
-
{info.electronVersion}
-
Node
-
{info.nodeVersion}
-
{t('settings.platform')}
-
{info.platform}
-
-
- - -
- {/* 关于 & 反馈:低频社区链接。http(s) 外链由 App 顶层点击拦截走 openExternal 在系统浏览器打开。 */} - -
-
-
-
- -
-
- {(saveError ?? openError) && ( - {saveError ?? openError} - )} - {saved && !anyChanged && {t('settings.saved')}} - -
-
-
- {llmEditor && ( + {s.llmEditor && ( setLlmEditor({ ...llmEditor, draft })} - onSave={() => void saveEditor()} - onCancel={closeEditor} + state={s.llmEditor} + existing={s.llm.profiles} + onChange={(draft) => s.setLlmEditor({ ...s.llmEditor!, draft })} + onSave={() => void s.saveLlmEditor()} + onCancel={s.closeLlmEditor} /> )} - {connEditor && ( + {s.connEditor && ( setConnEditor({ ...connEditor, draft })} - onSave={() => void saveConnEditor()} - onCancel={() => setConnEditor(null)} + state={s.connEditor} + onChange={(draft) => s.setConnEditor({ ...s.connEditor!, draft })} + onSave={() => s.saveConnEditor()} + onCancel={() => s.setConnEditor(null)} /> )} - {proxyEditor && ( + {s.proxyEditor && ( { - setProxy(proxyEditor); - setProxyEditor(null); - setSaved(false); - }} - onCancel={() => setProxyEditor(null)} + draft={s.proxyEditor} + onChange={s.setProxyEditor} + onSave={() => s.saveProxyEditor()} + onCancel={() => s.setProxyEditor(null)} /> )} - {connDeleteId && ( + {s.connDeleteId && ( c.id === connDeleteId)?.display_name || connDeleteId, + name: + s.connections.find((c) => c.id === s.connDeleteId)?.display_name || s.connDeleteId, })} confirmLabel={t('common.delete')} danger onConfirm={() => { - deleteConn(connDeleteId); - setConnDeleteId(null); + s.deleteConn(s.connDeleteId!); + s.setConnDeleteId(null); }} - onCancel={() => setConnDeleteId(null)} + onCancel={() => s.setConnDeleteId(null)} /> )} -
- ); -} - -/** 「检查更新」按钮(运行环境段):手动查 GitHub 最新版,自管 loading + 结果展示。 - * enabled=false(config.update.check_enabled 关闭)时禁用按钮并提示,不发起检测。 */ -function UpdateCheckButton({ enabled }: { enabled: boolean }) { - const { t } = useTranslation(); - const [checking, setChecking] = useState(false); - const [result, setResult] = useState(null); - if (!enabled) { - return ( - <> - - {t('settings.updateDisabledHint')} - - ); - } - const run = async (): Promise => { - setChecking(true); - try { - setResult(await invoke('app:checkUpdate', undefined)); - } catch (e) { - setResult({ - ok: false, - hasUpdate: false, - currentVersion: '', - error: e instanceof Error ? e.message : String(e), - }); - } finally { - setChecking(false); - } - }; - return ( - <> - - {result && - !checking && - (result.ok ? ( - result.hasUpdate ? ( - - ) : ( - {t('settings.upToDate')} - ) - ) : ( - {t('settings.checkFailed', { error: result.error })} - ))} ); } - -function ConnectionEditorModal({ - state, - onChange, - onSave, - onCancel, -}: { - state: { mode: 'add' | 'edit'; draft: ConnDraft }; - onChange: (draft: ConnDraft) => void; - onSave: () => void; - onCancel: () => void; -}) { - const { t } = useTranslation(); - const { mode, draft } = state; - const canSave = connDraftCanSave(draft); - return ( - // 二层模态:背景点击只关本层,stopPropagation 防冒泡到设置主模态的 onClose(否则会连设置一起关) -
{ - e.stopPropagation(); - onCancel(); - }} - > -
e.stopPropagation()} role="dialog"> -
-

- {mode === 'add' ? t('settings.addConnectionTitle') : t('settings.editConnectionTitle')} -

-
-
- {/* 平台选择仅新增时可改;编辑既有连接不允许切平台(base_url/token 语义不同) */} - {mode === 'add' && ( -
-
{t('settings.platform')}
-
- -
-
- )} - -
- - -
-
-
-
- ); -} - -function ProxyEditorModal({ - draft, - onChange, - onSave, - onCancel, -}: { - draft: Config['proxy']; - onChange: (next: Config['proxy']) => void; - onSave: () => void; - onCancel: () => void; -}) { - const { t } = useTranslation(); - const [test, setTest] = useState<{ - testing: boolean; - result: { ok: boolean; reason?: string } | null; - }>({ testing: false, result: null }); - const [pwVisible, setPwVisible] = useState(false); - // 改任意字段都清掉上次测试结果(避免误导) - const patch = (p: Partial): void => { - onChange({ ...draft, ...p }); - setTest({ testing: false, result: null }); - }; - return ( - // 二层模态:背景点击只关本层,stopPropagation 防冒泡到设置主模态的 onClose(否则会连设置一起关) -
{ - e.stopPropagation(); - onCancel(); - }} - > -
e.stopPropagation()} - role="dialog" - > -
-

{t('settings.proxyTitle')}

-
-
-

- {t('settings.proxyModalHint')} -

- - {draft.enabled && ( - <> - {/* 字段名放输入框前(modal-kv 网格);用户名 / 密码分上下两行,均可选 */} -
-
{t('settings.proxyHost')}
-
- patch({ host: e.target.value.trim() })} - placeholder={t('settings.proxyHostPlaceholder')} - aria-label={t('settings.proxyHostAria')} - /> -
-
{t('settings.proxyPort')}
-
- patch({ port: Number.parseInt(e.target.value, 10) || 0 })} - aria-label={t('settings.proxyPortAria')} - /> -
-
{t('settings.proxyUsername')}
-
- patch({ username: e.target.value })} - placeholder={t('settings.proxyUsernamePlaceholder')} - aria-label={t('settings.proxyUsernameAria')} - autoComplete="off" - /> -
-
{t('settings.proxyPassword')}
-
-
- patch({ password: e.target.value })} - placeholder={t('settings.proxyPasswordPlaceholder')} - aria-label={t('settings.proxyPasswordAria')} - autoComplete="off" - /> - -
-
-
-
- - {test.result && - (test.result.ok ? ( - - {t('settings.proxyOk')} - - ) : ( - - ✗ {test.result.reason ?? t('settings.testFailed')} - - ))} -
- - )} -
- - -
-
-
-
- ); -} - -function LlmEditorModal({ - state, - existing, - onChange, - onSave, - onCancel, -}: { - state: { mode: 'add' | 'edit'; draft: LlmProfile }; - existing: LlmProfile[]; - onChange: (draft: LlmProfile) => void; - onSave: () => void; - onCancel: () => void; -}) { - const { t } = useTranslation(); - const { mode, draft } = state; - // 点保存才把所有必填项暴露出来(LlmProfileForm 内部按 touched 渐进显示) - const [forceShowErrors, setForceShowErrors] = useState(false); - const isValid = Object.keys(validateProfile(draft, existing)).length === 0; - const trySave = (): void => { - if (!isValid) { - setForceShowErrors(true); - return; - } - onSave(); - }; - return ( - // 二层模态:背景点击只关本层,stopPropagation 防冒泡到设置主模态的 onClose(否则会连设置一起关) -
{ - e.stopPropagation(); - onCancel(); - }} - > -
e.stopPropagation()} role="dialog"> -
-

{mode === 'add' ? t('settings.addLlmTitle') : t('settings.editLlmTitle')}

-
-
- -
- - -
-
-
-
- ); -} - -function formatBytes(n: number): string { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; - return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx new file mode 100644 index 0000000..e75adff --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next'; +import { Modal } from '../../../common/Modal'; +import { ConnectionForm, connDraftCanSave, type ConnDraft } from '../ConnectionForm'; + +export function ConnectionEditorModal({ + state, + onChange, + onSave, + onCancel, +}: { + state: { mode: 'add' | 'edit'; draft: ConnDraft }; + onChange: (draft: ConnDraft) => void; + onSave: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const { mode, draft } = state; + const canSave = connDraftCanSave(draft); + return ( + + {/* 平台选择仅新增时可改;编辑既有连接不允许切平台(base_url/token 语义不同) */} + {mode === 'add' && ( +
+
{t('settings.platform')}
+
+ +
+
+ )} + +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx new file mode 100644 index 0000000..925056a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { LlmProfile } from '@meebox/shared'; +import { Modal } from '../../../common/Modal'; +import { LlmProfileForm, validateProfile } from '../LlmProfileForm'; + +export function LlmEditorModal({ + state, + existing, + onChange, + onSave, + onCancel, +}: { + state: { mode: 'add' | 'edit'; draft: LlmProfile }; + existing: LlmProfile[]; + onChange: (draft: LlmProfile) => void; + onSave: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const { mode, draft } = state; + // 点保存才把所有必填项暴露出来(LlmProfileForm 内部按 touched 渐进显示) + const [forceShowErrors, setForceShowErrors] = useState(false); + const isValid = Object.keys(validateProfile(draft, existing)).length === 0; + const trySave = (): void => { + if (!isValid) { + setForceShowErrors(true); + return; + } + onSave(); + }; + return ( + + +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx new file mode 100644 index 0000000..982c919 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; +import { Modal } from '../../../common/Modal'; +import { EyeIcon, EyeOffIcon } from '../../../common/icons'; +import { invoke } from '../../../../api'; + +export function ProxyEditorModal({ + draft, + onChange, + onSave, + onCancel, +}: { + draft: Config['proxy']; + onChange: (next: Config['proxy']) => void; + onSave: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const [test, setTest] = useState<{ + testing: boolean; + result: { ok: boolean; reason?: string } | null; + }>({ testing: false, result: null }); + const [pwVisible, setPwVisible] = useState(false); + // 改任意字段都清掉上次测试结果(避免误导) + const patch = (p: Partial): void => { + onChange({ ...draft, ...p }); + setTest({ testing: false, result: null }); + }; + return ( + +

+ {t('settings.proxyModalHint')} +

+ + {draft.enabled && ( + <> + {/* 字段名放输入框前(modal-kv 网格);用户名 / 密码分上下两行,均可选 */} +
+
{t('settings.proxyHost')}
+
+ patch({ host: e.target.value.trim() })} + placeholder={t('settings.proxyHostPlaceholder')} + aria-label={t('settings.proxyHostAria')} + /> +
+
{t('settings.proxyPort')}
+
+ patch({ port: Number.parseInt(e.target.value, 10) || 0 })} + aria-label={t('settings.proxyPortAria')} + /> +
+
{t('settings.proxyUsername')}
+
+ patch({ username: e.target.value })} + placeholder={t('settings.proxyUsernamePlaceholder')} + aria-label={t('settings.proxyUsernameAria')} + autoComplete="off" + /> +
+
{t('settings.proxyPassword')}
+
+
+ patch({ password: e.target.value })} + placeholder={t('settings.proxyPasswordPlaceholder')} + aria-label={t('settings.proxyPasswordAria')} + autoComplete="off" + /> + +
+
+
+
+ + {test.result && + (test.result.ok ? ( + + {t('settings.proxyOk')} + + ) : ( + ✗ {test.result.reason ?? t('settings.testFailed')} + ))} +
+ + )} +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx b/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx new file mode 100644 index 0000000..ededb83 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { UpdateCheckResult } from '@meebox/shared'; +import { invoke } from '../../../../api'; + +/** 「检查更新」按钮(运行环境段):手动查 GitHub 最新版,自管 loading + 结果展示。 */ +export function UpdateCheckButton({ enabled }: { enabled: boolean }) { + const { t } = useTranslation(); + const [checking, setChecking] = useState(false); + const [result, setResult] = useState(null); + if (!enabled) { + return ( + <> + + {t('settings.updateDisabledHint')} + + ); + } + const run = async (): Promise => { + setChecking(true); + try { + setResult(await invoke('app:checkUpdate', undefined)); + } catch (e) { + setResult({ + ok: false, + hasUpdate: false, + currentVersion: '', + error: e instanceof Error ? e.message : String(e), + }); + } finally { + setChecking(false); + } + }; + return ( + <> + + {result && + !checking && + (result.ok ? ( + result.hasUpdate ? ( + + ) : ( + {t('settings.upToDate')} + ) + ) : ( + {t('settings.checkFailed', { error: result.error })} + ))} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts new file mode 100644 index 0000000..847380a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts @@ -0,0 +1,370 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { AppPaths, Config, LlmProfile, SupportedLanguage } from '@meebox/shared'; +import { invoke } from '../../../../api'; +import i18n, { persistLanguage, resolveUiLanguage } from '../../../../i18n'; +import { fromConnDraft, toConnDraft, type ConnDraft } from '../ConnectionForm'; +import { newProfileId } from '../LlmProfileForm'; + +interface UseSettingsDraftParams { + config: Config; + paths: AppPaths; + onLlmChange?: (llm: Config['llm']) => void; + onProxyChange?: (proxy: Config['proxy']) => void; + onLanguageChange?: (language: SupportedLanguage) => void; + onConnectionsChange?: () => void | Promise; + onClose: () => void; +} + +/** + * SettingsModal 的「草稿 → 整体保存」状态机:所有编辑只改本地 state,点底栏「保存」才整体写盘 + + * 生效;连接 / LLM 改动额外自动写入 config.yaml(防丢失)但不应用到运行时。语言为即时生效项(不走全局 + * 保存)。对外暴露各分区所需的语义化 state 与 setter(编辑即标脏),以及编辑器弹窗状态与 saveAll。 + */ +export function useSettingsDraft({ + config, + paths, + onLlmChange, + onProxyChange, + onLanguageChange, + onConnectionsChange, + onClose, +}: UseSettingsDraftParams) { + const { t } = useTranslation(); + const [opening, setOpening] = useState(false); + const [openError, setOpenError] = useState(null); + + // 草稿 → 整体保存:所有编辑只改本地 state,点底栏"保存"才整体写盘 + 生效 + const [reposDirInput, setReposDirInput] = useState(config.workspace.repos_dir); + // Agent 其余字段(max_steps / summary_max_chars / autopilot)在 UI 不编辑,仅持有以便保存时 + // 原样回传、不被覆盖成默认值;只有目录经 agentDirInput 可编辑。 + const [agent] = useState(config.agent); + const [agentDirInput, setAgentDirInput] = useState(config.agent.dir); + const [pollerInput, setPollerInput] = useState(String(config.poller.interval_seconds)); + const [llm, setLlm] = useState(config.llm); + const [llmEditor, setLlmEditor] = useState<{ mode: 'add' | 'edit'; draft: LlmProfile } | null>( + null, + ); + const [proxy, setProxy] = useState(config.proxy); + // 代理在独立模态框里编辑:null=关闭,非 null=正在编辑的草稿;保存回 proxy,底栏「保存」才写盘。 + const [proxyEditor, setProxyEditor] = useState(null); + + // 连接:多条可配置 + 单选启用;编辑只改本地 state,整体保存才写盘 + 热重建 + const [connections, setConnections] = useState(config.connections); + const [activeConnId, setActiveConnId] = useState(config.active_connection_id); + const [connEditor, setConnEditor] = useState<{ mode: 'add' | 'edit'; draft: ConnDraft } | null>( + null, + ); + const [connDeleteId, setConnDeleteId] = useState(null); + + // 保存基线:保存成功后更新,用于 changed 判定(禁用保存按钮) + const [base, setBase] = useState(() => ({ + reposDir: config.workspace.repos_dir, + agentDir: config.agent.dir, + poller: config.poller.interval_seconds, + llm: config.llm, + proxy: config.proxy, + connections: config.connections, + activeConnId: config.active_connection_id, + })); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [saveError, setSaveError] = useState(null); + + // UI 语言:即时生效项(不走全局保存) + const [language, setLanguage] = useState(() => + resolveUiLanguage(config.language), + ); + const handleLanguageChange = (next: SupportedLanguage): void => { + if (next === language) return; + setLanguage(next); + void i18n.changeLanguage(next); // 渲染层实时切换 + persistLanguage(next); // localStorage 缓存,下次启动同步命中 + onLanguageChange?.(next); // 同步父级 boot.config.language + invoke('config:setLanguage', { language: next }).catch((e: unknown) => { + // 写盘 / 主进程切换失败不回滚 UI(已切),仅提示;下次启动按 localStorage 兜底 + setSaveError(e instanceof Error ? e.message : String(e)); + }); + }; + + const [totalBytes, setTotalBytes] = useState(null); + useEffect(() => { + invoke('repo:getTotalSize', undefined) + .then((r) => setTotalBytes(r.totalBytes)) + .catch(() => setTotalBytes(0)); + }, []); + + const openConfigFile = async (): Promise => { + setOpening(true); + setOpenError(null); + try { + await invoke('app:openConfigFile', undefined); + } catch (e) { + setOpenError(e instanceof Error ? e.message : String(e)); + } finally { + setOpening(false); + } + }; + + // 连接 / LLM 编辑:改本地 state + 自动写入 config.yaml(防丢失),但不应用到运行时 + const autosaveDraft = ( + nextConnections: Config['connections'], + activeId: string, + nextLlm: Config['llm'], + ): void => { + void invoke('config:autosaveDraft', { + connections: nextConnections, + active_connection_id: activeId, + llm: nextLlm, + }).catch(() => { + /* 自动保存失败不打断编辑;点底栏保存时会再写一次 */ + }); + }; + + // ── LLM 配置 ── + const persistLlm = (next: Config['llm']): void => { + setLlm(next); + setSaved(false); + onLlmChange?.(next); + autosaveDraft(connections, activeConnId, next); + }; + const openAddProfile = (): void => { + setLlmEditor({ + mode: 'add', + draft: { + id: newProfileId(), + label: '', + provider: 'openai-compatible', + base_url: '', + model: '', + api_key: '', + }, + }); + }; + const openEditProfile = (id: string): void => { + const p = llm.profiles.find((x) => x.id === id); + if (!p) return; + setLlmEditor({ mode: 'edit', draft: { ...p } }); + }; + const closeLlmEditor = (): void => setLlmEditor(null); + const saveLlmEditor = async (): Promise => { + if (!llmEditor) return; + const { mode, draft } = llmEditor; + const profiles = + mode === 'add' + ? [...llm.profiles, draft] + : llm.profiles.map((p) => (p.id === draft.id ? draft : p)); + const active_id = mode === 'add' && !llm.active_id ? draft.id : llm.active_id; + persistLlm({ profiles, active_id }); + setLlmEditor(null); + }; + const deleteProfile = (id: string): void => { + const profiles = llm.profiles.filter((p) => p.id !== id); + const active_id = llm.active_id === id ? (profiles[0]?.id ?? '') : llm.active_id; + persistLlm({ profiles, active_id }); + }; + const setActiveLlm = (id: string): void => { + if (llm.active_id === id) return; + persistLlm({ ...llm, active_id: id }); + }; + + // ── 连接 ── + const persistConnections = (next: Config['connections'], activeId: string): void => { + setConnections(next); + setActiveConnId(activeId); + setSaved(false); + autosaveDraft(next, activeId, llm); + }; + const openAddConn = (): void => { + setConnEditor({ + mode: 'add', + draft: { + id: newProfileId(), + kind: 'github', + display_name: '', + base_url: '', + token: '', + protocol: 'pat', + }, + }); + }; + const openEditConn = (id: string): void => { + const c = connections.find((x) => x.id === id); + if (c) setConnEditor({ mode: 'edit', draft: toConnDraft(c) }); + }; + const saveConnEditor = (): void => { + if (!connEditor) return; + const { mode, draft } = connEditor; + const conn = fromConnDraft(draft); + const next = + mode === 'add' ? [...connections, conn] : connections.map((c) => (c.id === conn.id ? conn : c)); + // 新增首条自动设为启用 + const activeId = mode === 'add' && !activeConnId ? conn.id : activeConnId; + persistConnections(next, activeId); + setConnEditor(null); + }; + const deleteConn = (id: string): void => { + const next = connections.filter((c) => c.id !== id); + // 删的是当前启用 → 启用回退到剩下第一条(无则空串,不轮询任何连接) + const activeId = activeConnId === id ? (next[0]?.id ?? '') : activeConnId; + persistConnections(next, activeId); + }; + const setActiveConn = (id: string): void => { + if (activeConnId === id) return; + persistConnections(connections, id); + }; + + // ── 代理 ── + const saveProxyEditor = (): void => { + if (!proxyEditor) return; + setProxy(proxyEditor); + setProxyEditor(null); + setSaved(false); + }; + + // ── 目录 / 轮询的语义化 setter(编辑即标脏)── + const setPoller = (seconds: number): void => { + setPollerInput(String(seconds)); + setSaved(false); + }; + const setAgentDir = (v: string): void => { + setAgentDirInput(v); + setSaved(false); + }; + const setReposDir = (v: string): void => { + setReposDirInput(v); + setSaved(false); + }; + const pickAgentDir = async (): Promise => { + const r = await invoke('dialog:pickDirectory', { + defaultPath: agentDirInput.trim() || paths.appDir, + title: t('settings.pickAgentDirTitle'), + }); + if (r.path) setAgentDir(r.path); + }; + const pickReposDir = async (): Promise => { + const r = await invoke('dialog:pickDirectory', { + defaultPath: reposDirInput.trim() || paths.reposDir, + title: t('settings.pickCacheDirTitle'), + }); + if (r.path) setReposDir(r.path); + }; + + // ── 变更检测(对比基线)+ 整体保存 ── + const reposDirChanged = reposDirInput.trim() !== base.reposDir; + const agentChanged = agentDirInput.trim() !== base.agentDir; + const pollerChanged = pollerInput.trim() !== String(base.poller); + const llmChanged = JSON.stringify(llm) !== JSON.stringify(base.llm); + const proxyChanged = JSON.stringify(proxy) !== JSON.stringify(base.proxy); + const connectionsChanged = + activeConnId !== base.activeConnId || + JSON.stringify(connections) !== JSON.stringify(base.connections); + const anyChanged = + reposDirChanged || + agentChanged || + pollerChanged || + llmChanged || + proxyChanged || + connectionsChanged; + + const saveAll = async (): Promise => { + if (saving || !anyChanged) return; + setSaving(true); + setSaveError(null); + setSaved(false); + try { + if (pollerChanged) { + const n = Number.parseInt(pollerInput, 10); + if (!Number.isFinite(n) || n < 60 || n > 900) throw new Error(t('settings.pollerRangeError')); + await invoke('config:setPoller', { interval_seconds: n }); + } + if (agentChanged) { + // 仅 UI 编辑 dir;其余字段从已加载的 config 原样保留,避免被覆盖成默认值。 + await invoke('config:setAgent', { agent: { ...agent, dir: agentDirInput.trim() } }); + } + if (llmChanged) { + await invoke('config:setLlm', { llm }); + onLlmChange?.(llm); + } + if (proxyChanged) { + await invoke('config:setProxy', { proxy }); + onProxyChange?.(proxy); + } + if (connectionsChanged) { + await invoke('config:setConnections', { connections, active_connection_id: activeConnId }); + await onConnectionsChange?.(); + } + if (reposDirChanged && reposDirInput.trim()) { + await invoke('config:setReposDir', { reposDir: reposDirInput.trim() }); + } + setBase({ + reposDir: reposDirInput.trim(), + agentDir: agentDirInput.trim(), + poller: Number.parseInt(pollerInput, 10), + llm, + proxy, + connections, + activeConnId, + }); + setSaved(true); + // 保存成功后自动关闭设置面板(失败则保持打开并展示 saveError) + onClose(); + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + return { + // 语言 + language, + handleLanguageChange, + // 连接 + connections, + activeConnId, + connEditor, + setConnEditor, + connDeleteId, + setConnDeleteId, + openAddConn, + openEditConn, + saveConnEditor, + deleteConn, + setActiveConn, + // LLM + llm, + llmEditor, + setLlmEditor, + openAddProfile, + openEditProfile, + closeLlmEditor, + saveLlmEditor, + deleteProfile, + setActiveLlm, + // 代理 + proxy, + proxyEditor, + setProxyEditor, + saveProxyEditor, + // 轮询 / 目录 + pollerInput, + setPoller, + agentDirInput, + setAgentDir, + pickAgentDir, + reposDirInput, + setReposDir, + pickReposDir, + totalBytes, + // 保存 / 配置文件 + opening, + openError, + openConfigFile, + saving, + saved, + saveError, + anyChanged, + saveAll, + }; +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/index.ts b/apps/desktop/src/renderer/src/components/features/settings/index.ts new file mode 100644 index 0000000..7af065d --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/index.ts @@ -0,0 +1,11 @@ +// features/settings 对外公共 API:设置面板 + 连接 / LLM 表单(onboarding 复用)。 +// 内部模块(sections / editors / hooks 等)相互引用走相对路径。状态栏 chip 走 statusbar/* 子路径。 +export { SettingsModal } from './SettingsModal'; +export { + ConnectionForm, + connDraftCanSave, + fromConnDraft, + type ConnDraft, + type ConnEntry, +} from './ConnectionForm'; +export { LlmProfileForm, newProfileId, LLM_PROVIDERS } from './LlmProfileForm'; diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx new file mode 100644 index 0000000..13c10c9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next'; +import { FolderIcon } from '../../../common/icons'; +import { invoke } from '../../../../api'; + +export function AgentDirSection({ + value, + onChange, + onPick, +}: { + value: string; + onChange: (v: string) => void; + onPick: () => void; +}) { + const { t } = useTranslation(); + return ( +
+ {/* 标题行:左侧标题 + 右侧蓝色「打开当前目录」按钮(在系统文件管理器打开生效的 Agent 目录, + 便于直接查看 / 编辑文件)。放在标题行而非配置行,避免与下方的目录选择按钮混淆。 */} +
+

{t('settings.agentDirTitle')}

+ {/* 文案按钮(非图标):与下方的目录「选择」图标按钮区分开,避免混淆。 */} + +
+

+ {t('settings.agentDirHint')} +

+
+ onChange(e.target.value)} + placeholder={t('settings.agentDirPlaceholder')} + /> + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/CacheDirSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/CacheDirSection.tsx new file mode 100644 index 0000000..7980d52 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/CacheDirSection.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next'; +import type { AppPaths } from '@meebox/shared'; +import { FolderIcon } from '../../../common/icons'; +import { formatBytes } from '../utils'; + +export function CacheDirSection({ + paths, + value, + onChange, + onPick, + totalBytes, +}: { + paths: AppPaths; + value: string; + onChange: (v: string) => void; + onPick: () => void; + totalBytes: number | null; +}) { + const { t } = useTranslation(); + return ( +
+

{t('settings.cacheDirTitle')}

+

+ {t('settings.cacheDirHint')} +

+
+
{t('settings.currentDir')}
+
{paths.reposDir}
+
{t('settings.cacheUsage')}
+
+ {totalBytes === null ? t('settings.calculating') : formatBytes(totalBytes)} +
+
+
+ onChange(e.target.value)} + placeholder="~/.code-meeseeks/repos" + /> + +
+

{t('settings.cacheDirRestartNote')}

+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ConnectionsSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ConnectionsSection.tsx new file mode 100644 index 0000000..374a640 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ConnectionsSection.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; +import { PencilIcon, TrashIcon } from '../../../common/icons'; +import { PLATFORM_META } from '../../../common/PlatformIcon'; + +export function ConnectionsSection({ + connections, + activeConnId, + onAdd, + onEdit, + onSetActive, + onRequestDelete, +}: { + connections: Config['connections']; + activeConnId: string; + onAdd: () => void; + onEdit: (id: string) => void; + onSetActive: (id: string) => void; + onRequestDelete: (id: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t('settings.connectionsTitle')}

+ +
+

+ {t('settings.connectionsHint')} +

+ {connections.length === 0 ? ( +

{t('settings.connectionsEmpty')}

+ ) : ( +
+ {connections.map((c) => { + const isActive = c.id === activeConnId; + const platformMeta = PLATFORM_META.find((m) => m.kind === c.kind); + return ( +
+ + {platformMeta && ( + + + + )} +
+
+ {c.display_name || c.id} +
+
+ {c.base_url} · clone via {c.clone.protocol === 'ssh' ? 'SSH' : 'PAT'} +
+
+ + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/LanguageSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/LanguageSection.tsx new file mode 100644 index 0000000..031c476 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/LanguageSection.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { LANGUAGE_OPTIONS, type SupportedLanguage } from '@meebox/shared'; + +export function LanguageSection({ + language, + onChange, +}: { + language: SupportedLanguage; + onChange: (next: SupportedLanguage) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t('settings.languageTitle')}

+ +
+

+ {t('settings.languageHint')} +

+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/LlmSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/LlmSection.tsx new file mode 100644 index 0000000..557dc07 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/LlmSection.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; +import { PencilIcon, TrashIcon } from '../../../common/icons'; +import { LlmProviderIcon } from '../../../common/LlmProviderIcon'; +import { providerLabel } from '../LlmProfileForm'; + +export function LlmSection({ + llm, + onAdd, + onEdit, + onSetActive, + onDelete, +}: { + llm: Config['llm']; + onAdd: () => void; + onEdit: (id: string) => void; + onSetActive: (id: string) => void; + onDelete: (id: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t('settings.llmTitle')}

+ +
+

+ {t('settings.llmHint')} +

+ {llm.profiles.length === 0 ? ( +

{t('settings.llmEmpty')}

+ ) : ( +
+ {llm.profiles.map((p) => { + const isActive = p.id === llm.active_id; + const titleText = p.label || t('settings.llmProfileFallback', { id: p.id.slice(0, 4) }); + const isCli = p.provider === 'cli'; + return ( +
+ + + + +
+
+ {titleText} + {isCli && ( + + {t('settings.experimental')} + + )} +
+
+ {providerLabel(p.provider)} + {p.model ? ` · ${p.model}` : ''} +
+
+ + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx new file mode 100644 index 0000000..8c84c01 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next'; +import type { CSSProperties } from 'react'; +import { POLLER_TIERS, nearestPollerIdx } from '../utils'; + +export function PollerSection({ + value, + onChange, +}: { + /** 当前轮询秒数(字符串形态,与底层 pollerInput 一致) */ + value: string; + /** 选定档位 → 回传该档秒数 */ + onChange: (seconds: number) => void; +}) { + const { t } = useTranslation(); + const pollerIdx = nearestPollerIdx(Number.parseInt(value, 10) || 300); + const pollerFillPct = (pollerIdx / (POLLER_TIERS.length - 1)) * 100; + return ( +
+

{t('settings.pollerTitle')}

+

+ {t('settings.pollerHint')} +

+
+
+ onChange(POLLER_TIERS[Number.parseInt(e.target.value, 10)]!)} + aria-label={t('settings.pollerSliderAria')} + /> + {/* 档位刻度:按 thumb 实际停靠位置绝对定位(thumb 宽 12px,两端内缩 6px), + translateX(-50%) 居中对齐;当前档位高亮 */} +
+ {POLLER_TIERS.map((tier, i) => { + const frac = i / (POLLER_TIERS.length - 1); + return ( + + {tier} + + ); + })} +
+
+ + {t('settings.pollerSeconds', { n: value })} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx new file mode 100644 index 0000000..48ebc4a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; + +export function ProxySection({ + proxy, + onConfigure, +}: { + proxy: Config['proxy']; + onConfigure: () => void; +}) { + const { t } = useTranslation(); + const on = proxy.enabled && !!proxy.host; + return ( +
+
+
+

{t('settings.proxyTitle')}

+ {/* 启用状态用 chip 表达(绿=已启用/灰=未启用),与应用其它状态视觉一致; + 地址不在此展示,详情见「配置」弹窗。 */} + + {on ? t('settings.proxyEnabledStatus') : t('settings.proxyDisabledStatus')} + +
+ +
+

+ {t('settings.proxyStatusHint')} +

+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx new file mode 100644 index 0000000..493e35b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from 'react-i18next'; +import type { AppInfo } from '@meebox/shared'; +import { GitHubMarkIcon, IssueIcon, TagIcon } from '../../../common/icons'; +import { invoke } from '../../../../api'; +import { UpdateCheckButton } from '../elements/UpdateCheckButton'; + +export function RuntimeSection({ + info, + updateEnabled, +}: { + info: AppInfo; + updateEnabled: boolean; +}) { + const { t } = useTranslation(); + return ( +
+

{t('settings.runtimeTitle')}

+
+
{t('settings.appVersion')}
+
{info.appVersion}
+
Electron
+
{info.electronVersion}
+
Node
+
{info.nodeVersion}
+
{t('settings.platform')}
+
{info.platform}
+
+
+ + +
+ {/* 关于 & 反馈:低频社区链接。http(s) 外链由 App 顶层点击拦截走 openExternal 在系统浏览器打开。 */} + +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/WorkDirSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/WorkDirSection.tsx new file mode 100644 index 0000000..b625501 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/WorkDirSection.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next'; +import type { AppPaths } from '@meebox/shared'; + +export function WorkDirSection({ paths }: { paths: AppPaths }) { + const { t } = useTranslation(); + return ( +
+

{t('settings.workDirTitle')}

+
+
{t('settings.appRoot')}
+
{paths.appDir}
+
{t('settings.configKey')}
+
{paths.configFile}
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/statusbar/LlmChip.tsx b/apps/desktop/src/renderer/src/components/features/settings/statusbar/LlmChip.tsx new file mode 100644 index 0000000..eea4fc7 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/statusbar/LlmChip.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; +import { StatusChip } from '../../../common/StatusChip'; + +/** + * 当前 active LLM profile 概要。点击展开下拉,列出所有 profile 直接切换。 + * 未配置时显示 "LLM: 未配置",点击直接打开设置。 + */ +export function LlmChip({ + llm, + onSwitch, + onOpenSettings, +}: { + llm: Config['llm']; + onSwitch: (id: string) => void; + onOpenSettings: () => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + // 点外面关菜单 + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent): void => { + const target = e.target as HTMLElement | null; + if (target?.closest('.llm-chip-menu') || target?.closest('.statusbar-llm-chip')) return; + setOpen(false); + }; + window.addEventListener('mousedown', onDown); + return () => window.removeEventListener('mousedown', onDown); + }, [open]); + + const active = llm.profiles.find((p) => p.id === llm.active_id); + const empty = !active; + const text = empty + ? t('statusBar.llmNotConfigured') + : active.model || active.label || active.provider; + const title = empty + ? t('statusBar.llmNotConfiguredTitle') + : `LLM: ${active.label || t('statusBar.unnamed')}\nprovider: ${active.provider}${ + active.model ? `\nmodel: ${active.model}` : '' + }${active.base_url ? `\nbase_url: ${active.base_url}` : ''}`; + + const onClick = (): void => { + if (empty || llm.profiles.length === 0) { + onOpenSettings(); + return; + } + setOpen((v) => !v); + }; + + return ( + + + LLM: {text} + + {open && ( +
+ {llm.profiles.map((p) => { + const isActive = p.id === llm.active_id; + return ( + + ); + })} +
+ +
+ )} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/statusbar/UserChip.tsx b/apps/desktop/src/renderer/src/components/features/settings/statusbar/UserChip.tsx new file mode 100644 index 0000000..14feb58 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/statusbar/UserChip.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from 'react-i18next'; +import type { ConnectionSummary } from '@meebox/shared'; +import { PersonIcon } from '../../../common/icons'; + +/** 当前连接的登录用户概要(多连接时带连接名前缀)。无可识别用户时不渲染。 */ +export function UserChip({ connections }: { connections: ConnectionSummary[] }) { + const { t } = useTranslation(); + const labels = connections + .filter((c) => c.user) + .map((c) => + connections.length > 1 ? `${c.displayName}: ${c.user!.displayName}` : c.user!.displayName, + ); + if (labels.length === 0) return null; + const title = connections + .map( + (c) => + `${c.displayName}: ${c.user ? `${c.user.displayName} (${c.user.name})` : t('statusBar.userUnidentified')}`, + ) + .join('\n'); + return ( + + + {labels.join(' · ')} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/utils.ts b/apps/desktop/src/renderer/src/components/features/settings/utils.ts new file mode 100644 index 0000000..53c2b68 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/utils.ts @@ -0,0 +1,20 @@ +// 轮询间隔档位(秒):低值细(30s 一档)、高值粗(分钟级),梯度放大。滑块拖的是 +// 档位索引而非秒数,从而实现非线性步长 + 离散刻度。 +export const POLLER_TIERS = [60, 90, 120, 180, 300, 600, 900]; + +/** 取最接近给定秒数的档位索引(配置值不在档位上时就近吸附) */ +export function nearestPollerIdx(seconds: number): number { + let best = 0; + for (let i = 1; i < POLLER_TIERS.length; i++) { + if (Math.abs(POLLER_TIERS[i]! - seconds) < Math.abs(POLLER_TIERS[best]! - seconds)) best = i; + } + return best; +} + +/** 字节数 → 人类可读(B / KB / MB / GB)。 */ +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} diff --git a/apps/desktop/src/renderer/src/components/layout/MainPane.tsx b/apps/desktop/src/renderer/src/components/layout/MainPane.tsx index 57b62c7..22e94f7 100644 --- a/apps/desktop/src/renderer/src/components/layout/MainPane.tsx +++ b/apps/desktop/src/renderer/src/components/layout/MainPane.tsx @@ -1,503 +1,9 @@ -import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import type { - LocalPrStatus, - PlatformCapabilities, - ReviewerStatus, - StoredPullRequest, -} from '@meebox/shared'; -import { invoke } from '../../api'; -import { useDraftsForPr } from '../../stores/drafts-store'; -import { CommentsPanel } from '../features/comments/CommentsPanel'; -import { CommitsPanel } from '../features/pr/CommitsPanel'; -// Monaco 编辑器(~10MB)懒加载:只有真正切到 Diff tab 才拉取 DiffView chunk, -// 不阻塞窗口首帧 / PR 列表 / 首启向导。 -const DiffView = lazy(() => import('../features/diff/DiffView').then((m) => ({ default: m.DiffView }))); -import { DraftsPanel } from '../features/drafts/DraftsPanel'; -import { PaneLoading } from '../common/Loading'; -import { PrInfoView } from '../features/pr/PrInfoView'; -import { PublishReviewModal } from '../features/drafts/PublishReviewModal'; -import { - ApproveIcon, - GlobeIcon, - NeedsWorkIcon, - PersonIcon, - PullRequestIcon, - WhitespaceIcon, -} from '../common/icons'; - -interface MainPaneProps { - pr: StoredPullRequest | null; - hasConnections: boolean; - onSetStatus: (status: LocalPrStatus) => void; - /** 合并当前 PR(仅在 mergeStatus.canMerge 时由 header 按钮触发) */ - onMerge: () => void; - /** 合并请求进行中:按钮置等待态并禁用,防重复点击(远端合并可能较慢)。 */ - merging?: boolean; - /** - * 当前 PR 所属连接的平台能力(多平台降级用)。undefined = 未知(无连接/旧数据)→ 不降级。 - * 据此决定审批按钮 显/隐(reviewStatuses)等。 - */ - capabilities?: PlatformCapabilities; - /** 当前 PR 所属连接的 PAT 用户登录名;用于判定「是否自己的 PR」(不能审批自己)。 */ - currentUserName?: string | null; - /** - * M4 跨组件跳转:ChatPane finding card 点"编辑"时由 App 设置,MainPane 据此 - * 切到 Diff tab + 把 nav 透传给 DiffView 做 scroll/highlight/open zone。 - * DiffView 消费完应调用 onDiffNavConsumed 清掉。 - */ - pendingDiffNav?: { - runId?: string; - findingId?: string; - anchor: { path: string; startLine: number; endLine: number }; - } | null; - onDiffNavConsumed?: () => void; - /** - * 反向通道:MainPane 内部组件 (e.g., PublishReviewModal) 也能触发 Diff 跳转。 - * App 端实际跑 setPendingDiffNav;MainPane 自己 useEffect 切 tab='diff' 并把 - * nav 透传给 DiffView,复用同一条消费链路 - */ - onRequestDiffNav?: (target: { - runId?: string; - findingId?: string; - anchor: { path: string; startLine: number; endLine: number }; - }) => void; -} - -type Tab = 'diff' | 'comments' | 'drafts' | 'commits' | 'info'; - -export function MainPane({ - pr, - hasConnections, - onSetStatus, - onMerge, - merging = false, - capabilities, - currentUserName, - pendingDiffNav, - onDiffNavConsumed, - onRequestDiffNav, -}: MainPaneProps) { - const { t } = useTranslation(); - const [tab, setTab] = useState('diff'); - // 收到跳转请求 → 强制切到 Diff tab,DiffView 自己负责消费 anchor - useEffect(() => { - if (pendingDiffNav) setTab('diff'); - }, [pendingDiffNav]); - const [renderSideBySide, setRenderSideBySide] = useState(() => { - const v = localStorage.getItem('meebox.diffMode'); - return v === null ? true : v === 'side-by-side'; - }); - // Blame 默认关:每次启动都得手动开(blame fetch 可能慢/失败,不希望 - // 用户进来就被错误 banner 干扰) - const [showBlame, setShowBlame] = useState(false); - // 空白字符可视化:默认关 (大多数代码 review 不关心空格 / tab;强调时再开) - const [showWhitespace, setShowWhitespace] = useState( - () => localStorage.getItem('meebox.showWhitespace') === '1', - ); - useEffect(() => { - localStorage.setItem('meebox.showWhitespace', showWhitespace ? '1' : '0'); - }, [showWhitespace]); - // 评论 / commits 数 chip: - // - 评论:调 diff:listComments — cache 命中 (pr.updatedAt 跟缓存一致) 时 - // cheap 回缓存;stale / cache miss 时主动拉远端写回缓存。打开 PR 时 - // 按需异步刷新最新评论计数,不依赖用户去点 Comments tab 触发 - // - commits:走本地 git rev-list base..head,镜像没拉齐 → 不显示数字 - // 都是 PR 切换时各拉一次,cancelled token 防 race。deps 含 pr?.updatedAt: - // Bitbucket 上加评论 / 状态变更后远端 updatedAt 跳变 → poller 拉到 → store 更新 → - // 这里 useEffect 重跑 → force 刷新评论 + 计数。用户 app 一直开着不切走 PR 时 - // 也能跟上远端变动 - const [commentCount, setCommentCount] = useState(null); - const [commitCount, setCommitCount] = useState(null); - const prLocalId = pr?.localId; - const prUpdatedAt = pr?.updatedAt; - useEffect(() => { - setCommentCount(null); - setCommitCount(null); - if (!prLocalId) return; - let cancelled = false; - void (async () => { - try { - const [cm, cc] = await Promise.all([ - // force:true 跳过 cache stale 比对 — 本地 PR.updatedAt 可能滞后于远端 - // (poller 周期性拉),stale 比对会误判命中。打开 PR 时强制刷一次拿到 - // 最新评论 + 计数 - invoke('diff:listComments', { localId: prLocalId, force: true }), - invoke('diff:commitCount', { localId: prLocalId }), - ]); - if (cancelled) return; - setCommentCount(cm.length); - setCommitCount(cc?.count ?? null); - } catch { - // 静默:角标不显示数字,不该挡用户视线 - } - })(); - return () => { - cancelled = true; - }; - }, [prLocalId, prUpdatedAt]); - useEffect(() => { - localStorage.setItem('meebox.diffMode', renderSideBySide ? 'side-by-side' : 'unified'); - }, [renderSideBySide]); - // 清掉历史遗留的 showBlame 持久化值;新逻辑不再读写它 - useEffect(() => { - if (localStorage.getItem('meebox.showBlame') !== null) { - localStorage.removeItem('meebox.showBlame'); - } - }, []); - - // M4 草稿池 → "提交评论 (N)" 按钮的 N。pending + edited 才算 publishable; - // rejected (用户决断不发) / posted (远端已发) 都排除 - const drafts = useDraftsForPr(prLocalId); - const publishableCount = useMemo( - () => - (drafts ?? []).reduce( - (n, d) => (d.status === 'pending' || d.status === 'edited' ? n + 1 : n), - 0, - ), - [drafts], - ); - // 草稿 tab 显示条件用总数 (任何 status 都算) —— 用户发完所有 pending 后仍可 - // 进 tab 看 posted/rejected 历史;只有从来没创建过草稿的 PR 才完全隐藏 tab - const totalDraftCount = (drafts ?? []).length; - const [publishModalOpen, setPublishModalOpen] = useState(false); - // 兜底:当前停在 'drafts' tab 但草稿全清空 (e.g., 用户手动删了最后一条) → 切回 - // 'diff' 避免显示孤儿空白内容区 - useEffect(() => { - if (tab === 'drafts' && totalDraftCount === 0) setTab('diff'); - }, [tab, totalDraftCount]); - - if (!pr) { - return ( -
-
- {hasConnections ? ( -
-

{t('mainPane.emptySelectPr')}

-

- {t('mainPane.emptySelectPrHint')} -

-
- ) : ( -
-

{t('mainPane.emptyNoConnections')}

-

- {t('mainPane.emptyNoConnectionsHint')} -

-
- )} -
-
- ); - } - - // 能力位降级:reviewStatuses 决定审批按钮显隐;自己作者的 PR 不能审批(GitHub 422, - // 其它平台也无意义)→ 灰显 + 原因。capabilities undefined(旧数据/无连接)时不降级。 - const reviewAllowed = (s: ReviewerStatus): boolean => - !capabilities || capabilities.reviewStatuses.includes(s); - const isOwnPr = !!currentUserName && pr.author.name === currentUserName; - const ownPrReason = isOwnPr ? t('mainPane.ownPrReason') : undefined; - - return ( -
-
-

- #{pr.remoteId} {pr.title} -

-
- {pr.hasConflict && ( - <> - - ⚠️ {t('mainPane.conflict')} - - · - - )} - - {pr.repo.projectKey}/{pr.repo.repoSlug} - - · {pr.author.displayName} - - {' '} - · {pr.sourceRef.displayId} → {pr.targetRef.displayId} - - · - {pr.localStatus} -
-
- - {t('mainPane.openInBrowser')} - - {/* approve / needs work:当前状态 = 高亮;点已高亮的回退到 pending(撤销远端标记)。 - 这两个 review 决断按钮右对齐,跟"浏览器打开"在左侧拉开距离。 - "提交评论 (N)" 放在决断按钮左边 — 评审动作分两步:先发评论 (左), - 再下决断 (右),从左到右符合阅读顺序。 - 文案用"评论"不用"评审":跟右侧"通过/需修改"两个评审决断按钮区分, - 本按钮只发评论,不下决断 (那是 /approve /needswork 的事) */} -
- {/* "提交评论" 仅在有待发布草稿时渲染:N=0 时整按钮隐藏 (而非 disabled - 灰显),减少 header 的视觉噪音。用户感知"还有 N 没发"的入口由文件树 - amber chip + 草稿 tab badge 共同承担 */} - {publishableCount > 0 && ( - - )} - {/* 合并按钮:仅在服务端判定可合并 (canMerge) 时出现。放在「通过」左侧、 - review 决断区内,避免与左侧「浏览器打开」相邻造成误触。点击直接合并 - (无二次确认);成功后 App 刷新列表,PR 转 MERGED 退场。 */} - {pr.mergeStatus?.canMerge && ( - - )} - {reviewAllowed('approved') && ( - - )} - {reviewAllowed('needsWork') && ( - - )} -
-
-
- -
- {tab === 'diff' && ( - }> - - - )} - {tab === 'comments' && ( - setCommentCount(n)} - capabilities={capabilities} - /> - )} - {tab === 'drafts' && ( - { - // 跟 PublishReviewModal 同套:查 draft 拿 anchor → 上抛 pendingDiffNav - // → App 切到 Diff tab。不带 runId/findingId 仅 navigate 不进 edit - const d = (drafts ?? []).find((x) => x.id === draftId); - if (!d) return; - onRequestDiffNav?.({ - anchor: { - path: d.anchor.path, - startLine: d.anchor.startLine, - endLine: d.anchor.endLine, - }, - }); - }} - /> - )} - {tab === 'commits' && } - {tab === 'info' && } -
- {publishModalOpen && ( - setPublishModalOpen(false)} - onJumpToAnchor={(draftId) => { - // 点 anchor → 关 modal + 转 pendingDiffNav 上抛给 App。从本 PR 草稿池 - // 反查 draft 拿 anchor;runId/findingId 不带 → DiffView 仅 navigate - // 不进 edit (用户想看代码上下文,不一定是要改草稿) - const d = (drafts ?? []).find((x) => x.id === draftId); - if (!d) return; - setPublishModalOpen(false); - onRequestDiffNav?.({ - anchor: { - path: d.anchor.path, - startLine: d.anchor.startLine, - endLine: d.anchor.endLine, - }, - }); - }} - /> - )} -
- ); +import type { ReactNode } from 'react'; + +/** + * 主内容区(layout 薄壳):仅提供语义化 `
` 槽位,内容由上层(App)按当前业务决定。 + * 不感知 PR 等具体领域,便于后续扩展非 PR 的主区业务——往这个槽里塞别的面板即可。 + */ +export function MainPane({ children }: { children: ReactNode }) { + return
{children}
; } diff --git a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx index f7057d7..8938c95 100644 --- a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx @@ -8,7 +8,7 @@ import type { } from '@meebox/shared'; import { invoke, subscribe } from '../../api'; import { useChatRunStore } from '../../stores/chat-run-store'; -import { PrItem } from '../features/pr/PrItem'; +import { PrItem } from '../features/pr'; // 'conflict' / 'mergeable' 是按远端 merge 状态跨 localStatus 横切的筛选;'all' 不限定 type FilterKey = 'all' | LocalPrStatus | 'conflict' | 'mergeable'; diff --git a/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx b/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx index 6f5d9d4..e4ee6c9 100644 --- a/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx @@ -1,19 +1,15 @@ -import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TFunction } from 'i18next'; import type { Config, ConnectionSummary, PrAgentStatus, UpdateCheckResult } from '@meebox/shared'; import { invoke } from '../../api'; -import { useChatRunStore } from '../../stores/chat-run-store'; -import { useRepoSyncStore } from '../../stores/repo-sync-store'; -import { - PanelToggleIcon, - PersonIcon, - PullRequestIcon, - RobotIcon, - RobotOffIcon, - SettingsIcon, - SyncIcon, -} from '../common/icons'; +import { PanelToggleIcon, SettingsIcon } from '../common/icons'; +import { StatusChip } from '../common/StatusChip'; +import { PrAgentActiveChip } from '../features/chat/statusbar/PrAgentActiveChip'; +import { AutopilotChip } from '../features/chat/statusbar/AutopilotChip'; +import { LlmChip } from '../features/settings/statusbar/LlmChip'; +import { UserChip } from '../features/settings/statusbar/UserChip'; +import { LastSyncChip } from '../features/pr/statusbar/LastSyncChip'; +import { RepoSyncChip } from '../features/pr/statusbar/RepoSyncChip'; +import { PrsCountChip } from '../features/pr/statusbar/PrsCountChip'; interface StatusBarProps { prsCount: number; @@ -43,6 +39,34 @@ interface StatusBarProps { onToggleAutopilot: () => void; } +/** pr-agent 运行时 chip:只显示版本(可用)/「不可用」(错误态),属应用运行时级,留在 layout。 */ +function PrAgentRuntimeChip({ status }: { status: PrAgentStatus }) { + const { t } = useTranslation(); + if (status.available) { + // chip 只显示 pr-agent 版本,不显示 strategy(embedded/local-cli 对用户无意义); + // embedded → `pr-agent 0.36.0` → 取 `0.36.0`;local-cli → help 首行截到首个空白前。 + const ver = + status.strategy === 'embedded' + ? status.version.replace(/^pr-agent\s+/, '') + : status.version.split(/\s+/)[0] || status.version; + return ( + + {t('statusBar.prAgentVersion', { ver })} + + ); + } + return ( + a.error).join('\n')}> + {t('statusBar.prAgentUnavailable')} + + ); +} + +/** + * 应用状态栏(薄壳):左侧折叠按钮 + 同步 / 仓库镜像 / pr-agent 运行时 / PR 计数 / 用户, + * 右侧 pr-agent 活动 / AutoPilot / LLM / 更新,末尾 chat 折叠 + 设置。各业务 chip 由其所属 + * feature 提供(features//statusbar/),本组件只做组合与布局。 + */ export function StatusBar({ prsCount, prAgent, @@ -76,40 +100,19 @@ export function StatusBar({ - {/* 当前正在 sync 的 repo (clone/fetch 中)。idle 不渲染,活动时实时显示阶段 + - 百分比,让用户感知"agent 正在更新仓库镜像"而不是 hang */} + {/* 当前正在 sync 的 repo (clone/fetch 中)。idle 不渲染 */} - {prAgent && } - - - {prsCount} - + {prAgent && } +
- {/* pr-agent 活动 / 空闲指示。PR 切换后这条仍然在,让用户随时看到"agent 在哪个 PR - 上跑 / 当前空闲"。放右侧贴近 LLM chip:一组都是"当前 run 用什么 / 跑得如何"的实时 - 信息。pr-agent 不可用时不显示 (上方 PrAgentChip 已经红色提示) */} + {/* pr-agent 活动 / 空闲指示。pr-agent 不可用时不显示(上方运行时 chip 已红色提示)。 */} {prAgent?.available && } - {/* AutoPilot 开关:默认关,点击切换(持久化到 agent.autopilot.enabled,下次 poll 生效)。 */} - + {updateInfo?.hasUpdate && updateInfo.url && ( - + )} - ) : ( - - {inner} - - )} - {queueOpen && expandable && ( - { - onJumpToPr?.(id); - setQueueOpen(false); - }} - /> - )} -
- ); -} - -/** - * 队列弹出菜单:状态栏 chip 上方弹出。先列全部运行中(active)行,再列 waiting 行。 - * waiting 行右侧 × 按钮取消。最多 6 个 item 高度,超出内部滚动。 - */ -function QueuePopover({ - active, - waiting, - onCancel, - onJumpToPr, -}: { - active: ReturnType['active']; - waiting: ReturnType['waiting']; - onCancel: (runId: string) => void; - onJumpToPr: (localId: string) => void; -}) { - const { t } = useTranslation(); - return ( -
-
- {t('statusBar.queueHeader')} - - {t('statusBar.queueSummary', { running: active.length, waiting: waiting.length })} - -
-
    - {active.map((a) => ( -
  • -
  • - ))} - {waiting.map((q) => ( -
  • -
  • - ))} -
-
- ); -} - -/** - * 状态栏 elapsed 格式:跟 ChatPane.RunResultView 的 `formatElapsed` 对齐 —— - * < 60s → "Ns" (例 "42s") - * >= 60s → "Mm SSs" (例 "1m 30s"),秒数两位补零定宽 - * 用 `m` / `s` 单位字面而不是 colon,避免跟时间戳 (HH:MM) 视觉混淆 - */ -function formatStatusbarElapsed(ms: number): string { - const totalSec = Math.max(0, Math.floor(ms / 1000)); - if (totalSec < 60) return `${String(totalSec)}s`; - const m = Math.floor(totalSec / 60); - const s = totalSec % 60; - return `${String(m)}m${String(s).padStart(2, '0')}s`; -} - -/** - * 当前 active LLM profile 概要。点击展开下拉,列出所有 profile 直接切换。 - * 未配置时显示 "LLM: 未配置",点击直接打开设置。 - */ -function LlmChip({ - llm, - onSwitch, - onOpenSettings, -}: { - llm: Config['llm']; - onSwitch: (id: string) => void; - onOpenSettings: () => void; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - // 点外面关菜单 - useEffect(() => { - if (!open) return; - const onDown = (e: MouseEvent): void => { - const target = e.target as HTMLElement | null; - if (target?.closest('.llm-chip-menu') || target?.closest('.statusbar-llm-chip')) return; - setOpen(false); - }; - window.addEventListener('mousedown', onDown); - return () => window.removeEventListener('mousedown', onDown); - }, [open]); - - const active = llm.profiles.find((p) => p.id === llm.active_id); - const empty = !active; - const text = empty ? t('statusBar.llmNotConfigured') : active.model || active.label || active.provider; - const title = empty - ? t('statusBar.llmNotConfiguredTitle') - : `LLM: ${active.label || t('statusBar.unnamed')}\nprovider: ${active.provider}${ - active.model ? `\nmodel: ${active.model}` : '' - }${active.base_url ? `\nbase_url: ${active.base_url}` : ''}`; - - const onClick = (): void => { - if (empty || llm.profiles.length === 0) { - onOpenSettings(); - return; - } - setOpen((v) => !v); - }; - - return ( - - - {open && ( -
- {llm.profiles.map((p) => { - const isActive = p.id === llm.active_id; - return ( - - ); - })} -
- -
- )} - - ); -} - -// 刷新按钮 + 同步状态合并:一个可点击 chip,显示最近同步相对时间 + 同步图标 -// (刷新中旋转),点击触发一次轮询。 -function LastSyncChip({ - at, - refreshing, - onRefresh, -}: { - at: string | null; - refreshing: boolean; - onRefresh: () => void; -}) { - const { t } = useTranslation(); - // 每 30s 重渲染一次,让 "刚刚 / N 分钟前" 文案随时间向前推进 - const [, tick] = useState(0); - useEffect(() => { - const id = setInterval(() => tick((n) => n + 1), 30_000); - return () => clearInterval(id); - }, []); - const date = at ? new Date(at) : null; - const label = refreshing ? t('statusBar.refreshing') : date ? formatRelative(date, t) : '—'; - const title = refreshing - ? t('statusBar.refreshing') - : date - ? t('statusBar.lastSyncTitle', { time: date.toLocaleString() }) - : t('statusBar.neverSyncedTitle'); - return ( - - ); -} - -function formatRelative(date: Date, t: TFunction): string { - const diffSec = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); - if (diffSec < 30) return t('statusBar.justNow'); - if (diffSec < 60) return t('statusBar.secondsAgo', { count: diffSec }); - const diffMin = Math.round(diffSec / 60); - if (diffMin < 60) return t('statusBar.minutesAgo', { count: diffMin }); - const diffHr = Math.round(diffMin / 60); - if (diffHr < 24) return t('statusBar.hoursAgo', { count: diffHr }); - // 超过 1 天直接给绝对时间,避免 "3 天前" 这种模糊 - return date.toLocaleString(); -} - -function PrAgentChip({ status }: { status: PrAgentStatus }) { - const { t } = useTranslation(); - if (status.available) { - // chip 只显示 pr-agent 版本,不显示 strategy(embedded/local-cli 对用户无意义; - // 完整 strategy + version 放 hover title)。version 来自 detect: - // - embedded → `pr-agent 0.36.0` → 取 `0.36.0` - // - local-cli → `pr-agent --help` 首行,截到首个空白前(避免长 usage 撑爆 chip) - const ver = - status.strategy === 'embedded' - ? status.version.replace(/^pr-agent\s+/, '') - : status.version.split(/\s+/)[0] || status.version; - return ( - - {t('statusBar.prAgentVersion', { ver })} - - ); - } - return ( - a.error).join('\n')} - > - {t('statusBar.prAgentUnavailable')} - - ); -} - -function UserChip({ connections }: { connections: ConnectionSummary[] }) { - const { t } = useTranslation(); - const labels = connections - .filter((c) => c.user) - .map((c) => - connections.length > 1 ? `${c.displayName}: ${c.user!.displayName}` : c.user!.displayName, - ); - if (labels.length === 0) return null; - const title = connections - .map( - (c) => `${c.displayName}: ${c.user ? `${c.user.displayName} (${c.user.name})` : t('statusBar.userUnidentified')}`, - ) - .join('\n'); - return ( - - - {labels.join(' · ')} - - ); -} - diff --git a/apps/desktop/src/renderer/src/hooks/useAppStores.ts b/apps/desktop/src/renderer/src/hooks/useAppStores.ts new file mode 100644 index 0000000..c1f116d --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useAppStores.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { wireChatRunStore } from '../stores/chat-run-store'; +import { wireDraftsStore } from '../stores/drafts-store'; +import { wireRepoSyncStore } from '../stores/repo-sync-store'; + +/** + * 把 IPC 事件流接到各全局 store(挂载到 React 树根,效果等价于「应用级 hook」): + * - chatRunStore:pr-agent 活动 run + 实时 stdout,ChatPane 跨 PR 切换可读回运行态 + * - repoSyncStore:repo 镜像 clone/fetch 进度,StatusBar 任意时刻可读当前同步任务 + * - draftsStore:草稿写盘后 drafts:changed 触发指定 PR 的草稿列表自动刷新 + */ +export function useAppStores(): void { + useEffect(() => wireChatRunStore(), []); + useEffect(() => wireRepoSyncStore(), []); + useEffect(() => wireDraftsStore(), []); +} diff --git a/apps/desktop/src/renderer/src/hooks/useBootstrap.ts b/apps/desktop/src/renderer/src/hooks/useBootstrap.ts new file mode 100644 index 0000000..e41c10a --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useBootstrap.ts @@ -0,0 +1,193 @@ +import { + useCallback, + useEffect, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; +import type { + AppInfo, + AppPaths, + Config, + ConnectionSummary, + PrAgentStatus, + StoredPullRequest, +} from '@meebox/shared'; +import { invoke, subscribe } from '../api'; +import i18n, { persistLanguage, resolveUiLanguage } from '../i18n'; +import type { OnboardingResult } from '../components/features/onboarding'; + +export interface BootstrapState { + info: AppInfo; + paths: AppPaths; + config: Config; + prAgent: PrAgentStatus; + connections: ConnectionSummary[]; + lastSyncAt: string | null; +} + +interface UseBootstrapParams { + /** PR 列表 store 的写入口(usePullRequests):启动 / 热刷新时注入最新列表。 */ + setPrs: Dispatch>; + /** 读缓存重拉 PR 列表(usePullRequests):poll tick / 窗口聚焦时调用。 */ + reloadPrs: () => Promise; +} + +/** + * 应用启动与全局生命周期:首帧前加载 boot(info/paths/config/prAgent + 初始 PR 列表 + 连接摘要 + + * 最近同步)并定档 UI 语言;运行时跟随 config.language 切换;订阅 poll tick(刷新最近同步 + 重拉 + * 列表 + 补连接摘要)与窗口聚焦刷新;并提供首启向导完成 / 连接热生效后的整体重载。派生 needsOnboarding + * 供 App 决定是否进向导。 + */ +export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { + boot: BootstrapState | null; + fatalError: string | null; + lastSyncAt: string | null; + needsOnboarding: boolean; + completeOnboarding: (result: OnboardingResult) => Promise; + refreshBootAndPrs: () => Promise; + /** 乐观更新 boot.config(IPC 写盘后本地同步,如切 LLM / 切 AutoPilot / 设置页改动)。 */ + patchConfig: (fn: (config: Config) => Config) => void; +} { + const [boot, setBoot] = useState(null); + const [fatalError, setFatalError] = useState(null); + const [lastSyncAt, setLastSyncAt] = useState(null); + // 仅调试用:localStorage 里 meebox.forceOnboarding='1' 时强制进首启向导,不必动 config.yaml。 + const [forceOnboarding, setForceOnboarding] = useState( + () => localStorage.getItem('meebox.forceOnboarding') === '1', + ); + + // 连接改动(尤其切换活动连接)后整体刷新 boot:活动连接变化后 main 端 app:connections / + // prs:list 都随之变,必须重拉,否则 boot.connections、PR 列表会过期。 + const refreshBootAndPrs = useCallback(async (): Promise => { + const [config, connections, freshPrs, lastSync] = await Promise.all([ + invoke('config:read', undefined), + invoke('app:connections', undefined), + invoke('prs:list', undefined), + invoke('prs:lastSync', undefined), + ]); + setBoot((b) => (b ? { ...b, config, connections, lastSyncAt: lastSync.at } : b)); + setPrs(freshPrs); + setLastSyncAt(lastSync.at); + }, [setPrs]); + + // 启动加载:拉齐 boot 数据 + 初始列表,并先把 UI 语言切到目标并等资源加载完,再 setBoot 渲染主界面。 + useEffect(() => { + void (async () => { + try { + if (!window.api) throw new Error('preload bridge missing: window.api is undefined'); + const [info, paths, config, prAgent, initialPrs, connections, lastSync] = await Promise.all([ + invoke('app:info', undefined), + invoke('app:paths', undefined), + invoke('config:read', undefined), + invoke('app:prAgentStatus', undefined), + invoke('prs:list', undefined), + invoke('app:connections', undefined), + invoke('prs:lastSync', undefined), + ]); + const lang = resolveUiLanguage(config.language); + persistLanguage(lang); + await i18n.changeLanguage(lang); + setBoot({ info, paths, config, prAgent, connections, lastSyncAt: lastSync.at }); + setPrs(initialPrs); + setLastSyncAt(lastSync.at); + } catch (e) { + setFatalError(e instanceof Error ? e.message : String(e)); + } + })(); + }, [setPrs]); + + // 运行时语言切换(如设置页改 config.language):boot 后 language 变化即切换并回写持久化。 + useEffect(() => { + if (!boot) return; + const lang = resolveUiLanguage(boot.config.language); + persistLanguage(lang); + void i18n.changeLanguage(lang); + }, [boot]); + + // poll tick:刷新「最近同步」+ 重拉列表(后台轮询新增/删除即时反映)+ 补连接摘要 + // (启动 ping 在建窗后才完成,借首轮 tick 把状态栏用户/能力位补上)。 + useEffect(() => { + if (!window.api) return; + return subscribe('poll:tick', (info) => { + setLastSyncAt(info.at); + void reloadPrs(); + void invoke('app:connections', undefined).then( + (connections) => setBoot((b) => (b ? { ...b, connections } : b)), + () => { + /* 摘要刷新失败不影响主流程 */ + }, + ); + }); + }, [reloadPrs]); + + // 窗口重新获得焦点时主动 refresh 远端:拉 PR meta,Bitbucket 上加 comment / 改状态后 + // PR.updatedAt 跳变 → PrPanel 的 prUpdatedAt dep 触发 → force listComments 拉新评论。 + useEffect(() => { + if (!boot) return; + const onFocus = (): void => { + void (async () => { + try { + await invoke('prs:refresh', undefined); + await reloadPrs(); + } catch { + // 静默:focus 触发的刷新失败不该弹错给用户 + } + })(); + }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [boot, reloadPrs]); + + // 首启向导完成:落盘连接(必)+ LLM / 缓存目录(按需),再整体重载 boot;boot.config 拿到有效 + // active 连接后 needsOnboarding 派生为 false,向导自然卸载、切入主界面。 + const completeOnboarding = useCallback( + async (result: OnboardingResult): Promise => { + await invoke('config:setConnections', { + connections: [result.connection], + active_connection_id: result.connection.id, + }); + if (result.llm) { + await invoke('config:setLlm', { + llm: { profiles: [result.llm], active_id: result.llm.id }, + }); + } + const trimmedRepos = result.reposDir.trim(); + // 注意:与初值不同才写盘 —— 这里用最新一次 read 比对(boot 闭包可能旧),交给 main 幂等即可。 + if (trimmedRepos) { + const cur = await invoke('config:read', undefined); + if (trimmedRepos !== (cur.workspace.repos_dir ?? '')) { + await invoke('config:setReposDir', { reposDir: trimmedRepos }); + } + } + await refreshBootAndPrs(); + // 走完向导清掉调试 flag,避免强制模式下完成后仍被困在向导 + if (forceOnboarding) { + localStorage.removeItem('meebox.forceOnboarding'); + setForceOnboarding(false); + } + }, + [forceOnboarding, refreshBootAndPrs], + ); + + // gate 条件 = 有无「有效的 active 连接」:连接为空 / active 悬空都触发首启向导。 + // 不依赖一次性 firstRun 标记 —— 用户清空连接后下次进入仍会回到向导。 + const needsOnboarding = + !!boot && + (forceOnboarding || + !boot.config.connections.some((c) => c.id === boot.config.active_connection_id)); + + const patchConfig = useCallback((fn: (config: Config) => Config): void => { + setBoot((b) => (b ? { ...b, config: fn(b.config) } : b)); + }, []); + + return { + boot, + fatalError, + lastSyncAt, + needsOnboarding, + completeOnboarding, + refreshBootAndPrs, + patchConfig, + }; +} diff --git a/apps/desktop/src/renderer/src/hooks/useExternalLinkGuard.ts b/apps/desktop/src/renderer/src/hooks/useExternalLinkGuard.ts new file mode 100644 index 0000000..5ef9ed2 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useExternalLinkGuard.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; +import { invoke } from '../api'; + +/** + * 全局外链跳转防护:所有 UGC 场景(评论 / PR 描述 / finding / chat 等)内的 + * `` 点击都走系统默认浏览器,不允许 Electron 在 app window 内直接跳转 + * 覆盖整个界面。capture 阶段 listener 先于 React onClick 跑。 + */ +export function useExternalLinkGuard(): void { + useEffect(() => { + const onClick = (e: MouseEvent): void => { + const target = (e.target as HTMLElement | null)?.closest?.('a[href]'); + if (!(target instanceof HTMLAnchorElement)) return; + const href = target.getAttribute('href'); + if (!href || !/^https?:\/\//.test(href)) return; + e.preventDefault(); + e.stopPropagation(); + void invoke('app:openExternal', { url: href }); + }; + document.addEventListener('click', onClick, true); + return () => document.removeEventListener('click', onClick, true); + }, []); +} diff --git a/apps/desktop/src/renderer/src/hooks/usePanelLayout.ts b/apps/desktop/src/renderer/src/hooks/usePanelLayout.ts new file mode 100644 index 0000000..d7d3fab --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/usePanelLayout.ts @@ -0,0 +1,59 @@ +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; +import { CHAT_MAX_WIDTH, CHAT_MIN_WIDTH } from '../components/features/chat'; +import { SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH } from '../components/layout/Sidebar'; + +function clampWidth(raw: string | null, min: number, max: number): number { + const n = raw ? Number(raw) : 360; + return Math.min(max, Math.max(min, Number.isFinite(n) ? n : 360)); +} + +/** + * 左右两栏(PR 列表 / chat)的宽度与折叠态:初值从 localStorage 读(夹到各自 min/max), + * 变化即回写。纯 UI 布局态,不涉及业务。 + */ +export function usePanelLayout(): { + sidebarWidth: number; + setSidebarWidth: Dispatch>; + sidebarCollapsed: boolean; + setSidebarCollapsed: Dispatch>; + chatWidth: number; + setChatWidth: Dispatch>; + chatCollapsed: boolean; + setChatCollapsed: Dispatch>; +} { + const [sidebarWidth, setSidebarWidth] = useState(() => + clampWidth(localStorage.getItem('meebox.sidebarWidth'), SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH), + ); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => localStorage.getItem('meebox.sidebarCollapsed') === '1', + ); + const [chatWidth, setChatWidth] = useState(() => + clampWidth(localStorage.getItem('meebox.chatWidth'), CHAT_MIN_WIDTH, CHAT_MAX_WIDTH), + ); + const [chatCollapsed, setChatCollapsed] = useState( + // 默认收起:chat 早期是空壳,避免空占地方 + () => (localStorage.getItem('meebox.chatCollapsed') ?? '1') === '1', + ); + useEffect(() => { + localStorage.setItem('meebox.sidebarWidth', String(sidebarWidth)); + }, [sidebarWidth]); + useEffect(() => { + localStorage.setItem('meebox.sidebarCollapsed', sidebarCollapsed ? '1' : '0'); + }, [sidebarCollapsed]); + useEffect(() => { + localStorage.setItem('meebox.chatWidth', String(chatWidth)); + }, [chatWidth]); + useEffect(() => { + localStorage.setItem('meebox.chatCollapsed', chatCollapsed ? '1' : '0'); + }, [chatCollapsed]); + return { + sidebarWidth, + setSidebarWidth, + sidebarCollapsed, + setSidebarCollapsed, + chatWidth, + setChatWidth, + chatCollapsed, + setChatCollapsed, + }; +} diff --git a/apps/desktop/src/renderer/src/hooks/useToast.ts b/apps/desktop/src/renderer/src/hooks/useToast.ts new file mode 100644 index 0000000..5375896 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useToast.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** + * 操作级 toast(审批 / 合并等远端动作失败时提示,区别于 fatalError 整屏报错)。 + * key 用随机数:同样文案连续触发也能重置自动消失计时器;6s 后自动消失。 + */ +export function useToast(): { + toast: { text: string; key: number } | null; + notifyError: (text: string) => void; + dismiss: () => void; +} { + const [toast, setToast] = useState<{ text: string; key: number } | null>(null); + const notifyError = useCallback((text: string): void => { + setToast({ text, key: Math.random() }); + }, []); + const dismiss = useCallback((): void => setToast(null), []); + useEffect(() => { + if (!toast) return; + const id = setTimeout(() => setToast(null), 6000); + return () => clearTimeout(id); + // 仅依赖 key:同一 toast 重渲不重置计时,新 toast (key 变) 才重置 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toast?.key]); + return { toast, notifyError, dismiss }; +} diff --git a/apps/desktop/src/renderer/src/hooks/useUpdateNotice.ts b/apps/desktop/src/renderer/src/hooks/useUpdateNotice.ts new file mode 100644 index 0000000..0e68476 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useUpdateNotice.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import type { UpdateCheckResult } from '@meebox/shared'; +import { invoke, subscribe } from '../api'; + +/** + * 版本更新提示(main 为单一真相源):挂载时水合已缓存结果(设置页手动检查 / 定时检查到的新版 + * 不因窗口重挂载而丢失),再订阅后续广播。另含 dev 调试钩子——控制台 dispatch + * `meebox:debug-update`(detail 可带 latestVersion / 为 null 清除)模拟「发现新版」验证状态栏 chip。 + */ +export function useUpdateNotice(): UpdateCheckResult | null { + const [updateInfo, setUpdateInfo] = useState(null); + useEffect(() => { + void invoke('app:getUpdateStatus', undefined).then((info) => { + if (info) setUpdateInfo(info); + }); + return subscribe('app:updateAvailable', (info) => setUpdateInfo(info)); + }, []); + useEffect(() => { + const onDebug = (e: Event): void => { + const d = (e as CustomEvent | null>).detail; + setUpdateInfo( + d === null + ? null + : { + ok: true, + hasUpdate: true, + currentVersion: '0.0.0', + latestVersion: '9.9.9', + url: 'https://github.com/huhamhire/code-meeseeks/releases/latest', + ...d, + }, + ); + }; + window.addEventListener('meebox:debug-update', onDebug); + return () => window.removeEventListener('meebox:debug-update', onDebug); + }, []); + return updateInfo; +} diff --git a/apps/desktop/src/renderer/src/editor-font.ts b/apps/desktop/src/renderer/src/lib/editor-font.ts similarity index 100% rename from apps/desktop/src/renderer/src/editor-font.ts rename to apps/desktop/src/renderer/src/lib/editor-font.ts diff --git a/apps/desktop/src/renderer/src/markdown.ts b/apps/desktop/src/renderer/src/lib/markdown.ts similarity index 100% rename from apps/desktop/src/renderer/src/markdown.ts rename to apps/desktop/src/renderer/src/lib/markdown.ts diff --git a/apps/desktop/src/renderer/src/monaco-setup.ts b/apps/desktop/src/renderer/src/lib/monaco-setup.ts similarity index 100% rename from apps/desktop/src/renderer/src/monaco-setup.ts rename to apps/desktop/src/renderer/src/lib/monaco-setup.ts diff --git a/apps/desktop/src/renderer/src/styles/modal.scss b/apps/desktop/src/renderer/src/styles/common/modal.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/modal.scss rename to apps/desktop/src/renderer/src/styles/common/modal.scss index 341f3c5..d1044ef 100644 --- a/apps/desktop/src/renderer/src/styles/modal.scss +++ b/apps/desktop/src/renderer/src/styles/common/modal.scss @@ -1,4 +1,4 @@ -@use './tokens' as *; +@use '../tokens' as *; .modal-backdrop { position: fixed; diff --git a/apps/desktop/src/renderer/src/styles/chat-pane.scss b/apps/desktop/src/renderer/src/styles/features/chat-pane.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/chat-pane.scss rename to apps/desktop/src/renderer/src/styles/features/chat-pane.scss index 48cb189..195a9d8 100644 --- a/apps/desktop/src/renderer/src/styles/chat-pane.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat-pane.scss @@ -1,6 +1,6 @@ // 右侧 pr-agent chat 面板 (M3-D 真实交互) -@use './tokens' as *; +@use '../tokens' as *; .chat-pane { flex-shrink: 0; diff --git a/apps/desktop/src/renderer/src/styles/comment-zone.scss b/apps/desktop/src/renderer/src/styles/features/comment-zone.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/comment-zone.scss rename to apps/desktop/src/renderer/src/styles/features/comment-zone.scss index 1ea1ad0..e845ebd 100644 --- a/apps/desktop/src/renderer/src/styles/comment-zone.scss +++ b/apps/desktop/src/renderer/src/styles/features/comment-zone.scss @@ -2,7 +2,7 @@ // 通过 DiffView 内 viewZone 注入 DOM;样式跟 markdown.scss 配合, // .comment-zone-body 也会加 .markdown class 走 markdown 视觉规则。 -@use './tokens' as *; +@use '../tokens' as *; // 结构 (跟 DraftZone 同): // .monaco-comment-zone (= dom,monaco 控制 style.height inline,不能放视觉样式) diff --git a/apps/desktop/src/renderer/src/styles/diff-search.scss b/apps/desktop/src/renderer/src/styles/features/diff-search.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/diff-search.scss rename to apps/desktop/src/renderer/src/styles/features/diff-search.scss index b4a0cdb..183e628 100644 --- a/apps/desktop/src/renderer/src/styles/diff-search.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff-search.scss @@ -1,4 +1,4 @@ -@use './tokens' as *; +@use '../tokens' as *; // DiffSearchPanel:替换文件树位置的搜索面板。 // 结构:input row → stats line → 结果列表 (按文件 group)。 diff --git a/apps/desktop/src/renderer/src/styles/diff.scss b/apps/desktop/src/renderer/src/styles/features/diff.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/diff.scss rename to apps/desktop/src/renderer/src/styles/features/diff.scss index 2f201e9..dc0d804 100644 --- a/apps/desktop/src/renderer/src/styles/diff.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff.scss @@ -1,4 +1,4 @@ -@use './tokens' as *; +@use '../tokens' as *; .diff-view { display: flex; diff --git a/apps/desktop/src/renderer/src/styles/draft-zone.scss b/apps/desktop/src/renderer/src/styles/features/draft-zone.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/draft-zone.scss rename to apps/desktop/src/renderer/src/styles/features/draft-zone.scss index c78ea77..d48c60d 100644 --- a/apps/desktop/src/renderer/src/styles/draft-zone.scss +++ b/apps/desktop/src/renderer/src/styles/features/draft-zone.scss @@ -1,7 +1,7 @@ // Monaco 行内草稿 view zone (M4 ADR-0007):跟 comment-zone 视觉区分 —— // remote 评论是黄底,草稿用蓝底 + "草稿" 标识;posted 状态切回绿底跟远端评论形态一致 -@use './tokens' as *; +@use '../tokens' as *; // **结构**: // .monaco-draft-zone (= dom,monaco 控制 style.height, 不能放视觉样式) diff --git a/apps/desktop/src/renderer/src/styles/drafts-panel.scss b/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/drafts-panel.scss rename to apps/desktop/src/renderer/src/styles/features/drafts-panel.scss index 40b68a8..a29ac7d 100644 --- a/apps/desktop/src/renderer/src/styles/drafts-panel.scss +++ b/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss @@ -1,4 +1,4 @@ -@use './tokens' as *; +@use '../tokens' as *; // 草稿管理面板 (M4)。布局跟 CommentsPanel 一致:上半个状态筛选条、下半个滚动 // 列表。每条卡片含 anchor + status chip + actions + body markdown + (可选) 错误 diff --git a/apps/desktop/src/renderer/src/styles/file-tree.scss b/apps/desktop/src/renderer/src/styles/features/file-tree.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/file-tree.scss rename to apps/desktop/src/renderer/src/styles/features/file-tree.scss index a31f621..a36b462 100644 --- a/apps/desktop/src/renderer/src/styles/file-tree.scss +++ b/apps/desktop/src/renderer/src/styles/features/file-tree.scss @@ -3,7 +3,7 @@ // 颜色跟 VS Code Git decoration 对齐:filename 走 $git-*;状态点用更亮的 Material 系 // 以在小尺寸点上保持识别度。 -@use './tokens' as *; +@use '../tokens' as *; .diff-file-list { // width 通过 inline style 由 DiffView 控制;CSS 端兜底最小值,避免 width 丢失时塌缩 diff --git a/apps/desktop/src/renderer/src/styles/onboarding.scss b/apps/desktop/src/renderer/src/styles/features/onboarding.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/onboarding.scss rename to apps/desktop/src/renderer/src/styles/features/onboarding.scss index e5c66ff..b4962bd 100644 --- a/apps/desktop/src/renderer/src/styles/onboarding.scss +++ b/apps/desktop/src/renderer/src/styles/features/onboarding.scss @@ -1,4 +1,4 @@ -@use './tokens' as *; +@use '../tokens' as *; // 首启配置向导:整屏覆盖,居中卡片 + 步骤圆点 + 轮播 slide .onboarding { diff --git a/apps/desktop/src/renderer/src/styles/pr-info.scss b/apps/desktop/src/renderer/src/styles/features/pr-info.scss similarity index 98% rename from apps/desktop/src/renderer/src/styles/pr-info.scss rename to apps/desktop/src/renderer/src/styles/features/pr-info.scss index b6c94f5..d1b9bca 100644 --- a/apps/desktop/src/renderer/src/styles/pr-info.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr-info.scss @@ -1,4 +1,4 @@ -@use './tokens' as *; +@use '../tokens' as *; .pr-info-view { flex: 1; diff --git a/apps/desktop/src/renderer/src/styles/main-pane.scss b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/main-pane.scss rename to apps/desktop/src/renderer/src/styles/layout/main-pane.scss index 35ded06..bef379b 100644 --- a/apps/desktop/src/renderer/src/styles/main-pane.scss +++ b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss @@ -1,4 +1,4 @@ -@use './tokens' as *; +@use '../tokens' as *; .main { flex: 1; diff --git a/apps/desktop/src/renderer/src/styles/sidebar.scss b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/sidebar.scss rename to apps/desktop/src/renderer/src/styles/layout/sidebar.scss index 18b258a..29a2610 100644 --- a/apps/desktop/src/renderer/src/styles/sidebar.scss +++ b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss @@ -1,4 +1,4 @@ -@use './tokens' as *; +@use '../tokens' as *; .app-body { flex: 1; diff --git a/apps/desktop/src/renderer/src/styles/statusbar.scss b/apps/desktop/src/renderer/src/styles/layout/statusbar.scss similarity index 99% rename from apps/desktop/src/renderer/src/styles/statusbar.scss rename to apps/desktop/src/renderer/src/styles/layout/statusbar.scss index f50fbec..90ad6fa 100644 --- a/apps/desktop/src/renderer/src/styles/statusbar.scss +++ b/apps/desktop/src/renderer/src/styles/layout/statusbar.scss @@ -1,6 +1,6 @@ // StatusBar (底部状态栏) -@use './tokens' as *; +@use '../tokens' as *; .app-statusbar { display: flex; diff --git a/apps/desktop/src/renderer/src/styles/titlebar.scss b/apps/desktop/src/renderer/src/styles/layout/titlebar.scss similarity index 98% rename from apps/desktop/src/renderer/src/styles/titlebar.scss rename to apps/desktop/src/renderer/src/styles/layout/titlebar.scss index 623627d..f297b2f 100644 --- a/apps/desktop/src/renderer/src/styles/titlebar.scss +++ b/apps/desktop/src/renderer/src/styles/layout/titlebar.scss @@ -1,4 +1,4 @@ -@use './tokens' as *; +@use '../tokens' as *; // 无边框窗口的自绘标题栏。高度必须与 main 进程 titleBarOverlay.height 一致(36px)。 .app-titlebar { diff --git a/apps/desktop/src/renderer/src/utils/time.ts b/apps/desktop/src/renderer/src/utils/time.ts new file mode 100644 index 0000000..a90e7ee --- /dev/null +++ b/apps/desktop/src/renderer/src/utils/time.ts @@ -0,0 +1,30 @@ +import type { TFunction } from 'i18next'; + +/** + * 把毫秒时长格式化为 "Ns" / "Mm SSs": + * < 60s → "42s" + * >= 60s → "1m 30s"(秒两位补零定宽);`compact` 时去掉空格 → "1m30s"(状态栏紧凑场景) + * 用 `m` / `s` 单位字面而非冒号,避免跟时间戳 (HH:MM) 视觉混淆。 + */ +export function formatElapsed(ms: number, opts?: { compact?: boolean }): string { + const totalSec = Math.max(0, Math.floor(ms / 1000)); + if (totalSec < 60) return `${String(totalSec)}s`; + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${String(m)}m${opts?.compact ? '' : ' '}${String(s).padStart(2, '0')}s`; +} + +/** + * 把时间点格式化为相对时间:"刚刚 / N 秒前 / N 分钟前 / N 小时前";超过 1 天给绝对时间, + * 避免 "3 天前" 这种模糊。 + */ +export function formatRelative(date: Date, t: TFunction): string { + const diffSec = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); + if (diffSec < 30) return t('statusBar.justNow'); + if (diffSec < 60) return t('statusBar.secondsAgo', { count: diffSec }); + const diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return t('statusBar.minutesAgo', { count: diffMin }); + const diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return t('statusBar.hoursAgo', { count: diffHr }); + return date.toLocaleString(); +}