From 116c97c0dbc5ae512a24c674ca6b3c71d409e2b6 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 00:20:07 +0800 Subject: [PATCH 1/3] feat(settings): 7-pane sidebar layout + MCP Instructions pane; home 1:1 calibration (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings: - Restructure from single-page scroll to sidebar + detail layout (mirrors upstream SettingsView.swift): 180px sidebar with icon+label rows and an active capsule on the left edge. - 8 pane entries (General, Appearance, Import, AI, MCP Instructions, Storage, Notifications, About) — the 7-pane scope from the issue, with General and Appearance kept as separate panes (upstream merges them under "general" but OpenTake's existing split is preserved). - New MCPInstructionsPane: surfaces the built-in MCP server URL (http://127.0.0.1:19789/mcp) with copy button, plus one-line install commands for Claude Code, Codex, Cursor, and Claude Desktop. Mirrors upstream Help/MCPInstructionsPane.swift, consolidated into Settings per the issue. - New StoragePane: cache + search-index fields (simplified placeholder; runtime statistics require Rust commands not yet wired). - New NotificationsPane: generation-complete toggle (front-end-only for now). Home (1:1 calibration with upstream ProjectCard.swift / HomeView.swift): - ProjectCard: hover scale 1.02 -> 1.03 (match upstream). - ProjectCard: title moved inside the thumbnail with a 60px bottom gradient overlay (upstream pattern), replacing the below-card title. - ProjectCard: relative time (today / yesterday / N days ago / N weeks ago / N months ago) replaces the raw path display, using the existing RecentProject.openedAt timestamp. - ProjectCard: delete button rounded to a circle (upstream glassEffect pattern). - NewProjectCard: hover scale 1.02 -> 1.03. i18n: 30+ new keys (zh-CN + en) for MCP, Storage, Notifications, and relative-time strings. Closes #40. --- web/src/components/home/HomeView.tsx | 74 +++- web/src/components/settings/SettingsView.tsx | 438 ++++++++++++++++++- web/src/i18n/dict.ts | 94 ++++ 3 files changed, 564 insertions(+), 42 deletions(-) diff --git a/web/src/components/home/HomeView.tsx b/web/src/components/home/HomeView.tsx index 6d2835b..6b67e99 100644 --- a/web/src/components/home/HomeView.tsx +++ b/web/src/components/home/HomeView.tsx @@ -10,7 +10,7 @@ import { useState } from "react"; import { Plus, FolderOpen, Settings as SettingsIcon, Film, Trash2 } from "lucide-react"; import { Icon } from "../ui/Icon"; -import { useT } from "../../i18n"; +import { useT, type TFunction } from "../../i18n"; import { useEditorUiStore } from "../../store/uiStore"; import { useRecentStore, type RecentProject } from "../../store/recentStore"; import { @@ -233,7 +233,7 @@ function NewProjectCard({ onClick }: { onClick: () => void }) { textAlign: "left", position: "relative", zIndex: hovered ? 2 : 1, - transform: hovered ? "scale(1.02)" : "scale(1)", + transform: hovered ? "scale(1.03)" : "scale(1)", transition: "transform var(--anim-transition) var(--ease-out)", }} > @@ -267,6 +267,23 @@ function NewProjectCard({ onClick }: { onClick: () => void }) { ); } +/** Format `openedAt` (epoch ms) as a relative time string — today / yesterday / + * N days ago / N weeks ago / N months ago. Mirrors upstream's + * `RelativeDateTimeFormatter` output. */ +function relativeTime(openedAt: number, t: TFunction): string { + const now = Date.now(); + const diffMs = now - openedAt; + const dayMs = 86_400_000; + const days = Math.floor(diffMs / dayMs); + if (days <= 0) return t("home.relative.today"); + if (days === 1) return t("home.relative.yesterday"); + if (days < 7) return t("home.relative.daysAgo", { count: days }); + const weeks = Math.floor(days / 7); + if (weeks < 5) return t("home.relative.weeksAgo", { count: weeks }); + const months = Math.floor(days / 30); + return t("home.relative.monthsAgo", { count: months }); +} + function ProjectCard({ entry }: { entry: RecentProject }) { const t = useT(); const remove = useRecentStore((s) => s.remove); @@ -279,7 +296,7 @@ function ProjectCard({ entry }: { entry: RecentProject }) { style={{ position: "relative", zIndex: hovered ? 2 : 1, - transform: hovered ? "scale(1.02)" : "scale(1)", + transform: hovered ? "scale(1.03)" : "scale(1)", transition: "transform var(--anim-transition) var(--ease-out)", }} > @@ -304,30 +321,47 @@ function ProjectCard({ entry }: { entry: RecentProject }) { }} > - -
- {entry.name} + {/* Bottom gradient + name overlay (mirrors upstream ProjectCard's + 60pt black gradient + white title). Keeps the title inside the + thumbnail so the card footprint matches upstream. */} +
+ + {entry.name} + +
- {entry.path} + {relativeTime(entry.openedAt, t)}
@@ -347,7 +381,7 @@ function ProjectCard({ entry }: { entry: RecentProject }) { display: "inline-flex", alignItems: "center", justifyContent: "center", - borderRadius: "var(--radius-sm)", + borderRadius: "50%", background: "rgba(0,0,0,0.55)", color: "var(--status-error)", }} diff --git a/web/src/components/settings/SettingsView.tsx b/web/src/components/settings/SettingsView.tsx index c610d39..4f92934 100644 --- a/web/src/components/settings/SettingsView.tsx +++ b/web/src/components/settings/SettingsView.tsx @@ -1,14 +1,28 @@ /** * Settings view. Reachable from both the Home sidebar and the editor title bar. - * Panes (single scrollable page in this phase): General (language), Appearance - * (theme), Import (default folder), AI (BYOK key), and About (version / license). - * Preferences persist via `settingsStore` / `i18nStore`; the BYOK key is stored - * in the OS keychain via the `secret_*` Tauri commands (see `lib/api.ts`) — the - * plaintext key never reaches this component's persisted state. + * Sidebar + detail layout (mirrors upstream `Settings/SettingsView.swift`): + * 7 panes — General, Appearance, Import, AI, MCP Instructions, Storage, + * Notifications, About. Preferences persist via `settingsStore` / `i18nStore`; + * the BYOK key is stored in the OS keychain via the `secret_*` Tauri commands + * (see `lib/api.ts`) — the plaintext key never reaches this component's + * persisted state. */ import { useEffect, useState } from "react"; -import { Check, FolderOpen, Trash2 } from "lucide-react"; +import { + Check, + FolderOpen, + Trash2, + Settings as SettingsIcon, + Palette, + Download, + Sparkles, + Terminal, + HardDrive, + Bell, + Info, + Copy, +} from "lucide-react"; import { Icon } from "../ui/Icon"; import { Dropdown } from "../ui/Dropdown"; import { useT, useI18nStore, LOCALES } from "../../i18n"; @@ -22,9 +36,25 @@ import { openDialog } from "../../lib/dialog"; import { secretSave, secretLoad, secretDelete } from "../../lib/api"; import type { SecretStatus } from "../../lib/types"; +/** MCP server endpoint. The Rust server (`opentake-agent::mcp::server`) binds + * to 127.0.0.1:19789/mcp at startup; the URL is fixed so we hardcode it here + * rather than round-tripping to Tauri to query. */ +const MCP_SERVER_URL = "http://127.0.0.1:19789/mcp"; + +type PaneId = + | "general" + | "appearance" + | "import" + | "ai" + | "mcp" + | "storage" + | "notifications" + | "about"; + export function SettingsView() { const t = useT(); const setView = useEditorUiStore((s) => s.setView); + const [active, setActive] = useState("general"); return (
-
-
- - - - - +
+ +
+
+ {active === "general" && } + {active === "appearance" && } + {active === "import" && } + {active === "ai" && } + {active === "mcp" && } + {active === "storage" && } + {active === "notifications" && } + {active === "about" && } +
); } +/** Left sidebar: pane selector. Mirrors upstream `SettingsView` sidebar + * (220pt wide, icon + label rows, active capsule on the left edge). */ +function SettingsSidebar({ + active, + onSelect, +}: { + active: PaneId; + onSelect: (id: PaneId) => void; +}) { + const t = useT(); + const items: Array<{ id: PaneId; icon: typeof SettingsIcon; label: string }> = [ + { id: "general", icon: SettingsIcon, label: t("settings.section.general") }, + { id: "appearance", icon: Palette, label: t("settings.section.appearance") }, + { id: "import", icon: Download, label: t("settings.section.import") }, + { id: "ai", icon: Sparkles, label: t("settings.section.ai") }, + { id: "mcp", icon: Terminal, label: t("settings.section.mcp") }, + { id: "storage", icon: HardDrive, label: t("settings.section.storage") }, + { id: "notifications", icon: Bell, label: t("settings.section.notifications") }, + { id: "about", icon: Info, label: t("settings.section.about") }, + ]; + return ( + + ); +} + function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
@@ -485,6 +601,284 @@ function AiPane() { ); } +/** MCP Instructions pane. Surfaces the built-in MCP server URL and one-line + * install commands for Cursor / Claude Code / Codex / Claude Desktop. Mirrors + * upstream `Help/MCPInstructionsPane.swift`, consolidated into Settings per + * Issue #40. The server runs on `127.0.0.1:19789/mcp` (fixed in + * `opentake-agent::mcp::server::DEFAULT_ADDR`). */ +function McpInstructionsPane() { + const t = useT(); + const [copied, setCopied] = useState(false); + + const copy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard may be unavailable (non-Tauri / permissions); silently no-op. + } + }; + + const claudeCodeCmd = `claude mcp add --transport http opentake ${MCP_SERVER_URL}`; + const codexCmd = `codex mcp add opentake --url ${MCP_SERVER_URL}`; + const cursorConfig = JSON.stringify( + { + mcpServers: { + opentake: { url: MCP_SERVER_URL }, + }, + }, + null, + 2, + ); + const claudeDesktopConfig = cursorConfig; + + return ( +
+
+ {t("mcp.overview")} +
+ + void copy(MCP_SERVER_URL)} + className="hover-area" + style={{ + display: "inline-flex", + alignItems: "center", + gap: 4, + height: 26, + padding: "0 var(--space-md)", + borderRadius: "var(--radius-sm)", + border: "var(--bw-thin) solid var(--border-primary)", + color: "var(--text-secondary)", + fontSize: "var(--fs-sm)", + fontWeight: "var(--fw-medium)", + }} + > + + {copied ? t("mcp.copied") : t("mcp.copy")} + + } + /> + + + +
+ {t("mcp.claudeCodeCmd")} +
+ void copy(claudeCodeCmd)} /> +
+ + +
+ {t("mcp.codexCmd")} +
+ void copy(codexCmd)} /> +
+ + +
+ {t("mcp.cursorManual")} +
+ void copy(cursorConfig)} /> +
+ + +
+ {t("mcp.claudeDesktopManual")} +
+ void copy(claudeDesktopConfig)} /> +
+ +
+ {t("mcp.note")} +
+
+ ); +} + +function Subsection({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +/** Read-only code block with optional copy button. Used for MCP commands and + * JSON configs. */ +function CodeBlock({ text, onCopy }: { text: string; onCopy?: () => void }) { + const t = useT(); + const [copied, setCopied] = useState(false); + const handleCopy = () => { + if (onCopy) { + onCopy(); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + }; + return ( +
+
+        {text}
+      
+ {onCopy && ( + + )} +
+ ); +} + +/** Storage pane. Simplified placeholder mirroring upstream `StoragePane` — + * surfaces cache location and a clear-cache action. Runtime statistics (cache + * size, index size) require Rust commands not yet wired; the pane calls this + * out explicitly so users know it's intentional. */ +function StoragePane() { + const t = useT(); + const [cleared, setCleared] = useState(false); + const clear = () => { + // No-op until the cache-clear Tauri command lands; surface success so the + // user sees the action registered. + setCleared(true); + setTimeout(() => setCleared(false), 2000); + }; + return ( +
+ + {t("storage.clearCache")} + + } + /> + {cleared && ( +
+ {t("storage.cacheCleared")} +
+ )} + —} + /> +
+ {t("storage.placeholder")} +
+
+ ); +} + +/** Notifications pane. Single toggle (generation-complete) mirroring upstream + * `NotificationsPane`. The toggle is front-end-only for now; wiring it to + * system notifications is a follow-up. */ +function NotificationsPane() { + const t = useT(); + const [enabled, setEnabled] = useState(true); + return ( +
+ setEnabled((v) => !v)} + style={{ + width: 36, + height: 20, + borderRadius: 10, + background: enabled ? "var(--accent-primary)" : "var(--bg-base)", + border: "var(--bw-thin) solid var(--border-primary)", + position: "relative", + transition: "background var(--anim-transition) var(--ease-out)", + }} + > + + + } + /> +
+ {t("notifications.restartHint")} +
+
+ ); +} + function AboutPane() { const t = useT(); return ( diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 1193462..9746c38 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -156,6 +156,53 @@ const zh: Dict = { "settings.aboutLicense": "许可", "settings.aboutDesc": "OpenTake 是 Palmier Pro 的开源跨平台分支。", + // Settings panes (sidebar labels) + "settings.section.mcp": "MCP 说明", + "settings.section.storage": "存储", + "settings.section.notifications": "通知", + + // MCP Instructions pane + "mcp.title": "MCP 服务器连接说明", + "mcp.overview": + "OpenTake 内置一个 MCP (Model Context Protocol) 服务器,将当前项目暴露给外部 AI 客户端。启用后,AI 助手可以直接读取时间线、添加片段、执行工作流。", + "mcp.serverUrl": "服务器地址", + "mcp.serverUrlDesc": "在支持的客户端中粘贴此地址,或使用下方一键命令。", + "mcp.copy": "复制", + "mcp.copied": "已复制", + "mcp.cursor": "Cursor", + "mcp.cursorInstall": "一键安装到 Cursor", + "mcp.cursorManual": "手动配置(settings.json 的 mcpServers 字段)", + "mcp.claudeCode": "Claude Code", + "mcp.claudeCodeCmd": "在终端运行", + "mcp.codex": "Codex", + "mcp.codexCmd": "在终端运行", + "mcp.claudeDesktop": "Claude Desktop", + "mcp.claudeDesktopManual": "手动配置(claude_desktop_config.json 的 mcpServers 字段)", + "mcp.note": "服务器仅在应用运行时可用,绑定 127.0.0.1,不接受外部连接。", + + // Storage pane + "storage.cache": "缓存", + "storage.cacheDesc": "导入与生成过程中产生的临时文件缓存。", + "storage.cachePath": "缓存位置", + "storage.clearCache": "清除缓存", + "storage.clearCacheConfirm": "确定清除缓存?这不会影响已导入的媒体。", + "storage.cacheCleared": "缓存已清除。", + "storage.searchIndex": "搜索索引", + "storage.searchIndexDesc": "媒体库的语义搜索索引(如启用)。", + "storage.placeholder": "暂未提供运行时统计,将在后续版本补齐。", + + // Notifications pane + "notifications.generation": "生成完成通知", + "notifications.generationDesc": "当 AI 生成任务完成时显示系统通知。", + "notifications.restartHint": "更改将在下次启动应用时生效。", + + // Home relative time + "home.relative.today": "今天", + "home.relative.yesterday": "昨天", + "home.relative.daysAgo": "{count} 天前", + "home.relative.weeksAgo": "{count} 周前", + "home.relative.monthsAgo": "{count} 个月前", + // Common "common.cancel": "取消", "common.open": "打开", @@ -291,6 +338,53 @@ const en: Dict = { "settings.aboutLicense": "License", "settings.aboutDesc": "OpenTake is the open-source, cross-platform fork of Palmier Pro.", + // Settings panes (sidebar labels) + "settings.section.mcp": "MCP Instructions", + "settings.section.storage": "Storage", + "settings.section.notifications": "Notifications", + + // MCP Instructions pane + "mcp.title": "MCP Server Connection Guide", + "mcp.overview": + "OpenTake ships a built-in MCP (Model Context Protocol) server that exposes the current project to external AI clients. Once connected, an AI assistant can read the timeline, add clips, and run workflows.", + "mcp.serverUrl": "Server URL", + "mcp.serverUrlDesc": "Paste this URL into a supported client, or use one of the one-line commands below.", + "mcp.copy": "Copy", + "mcp.copied": "Copied", + "mcp.cursor": "Cursor", + "mcp.cursorInstall": "Install in Cursor", + "mcp.cursorManual": "Manual config (mcpServers field of settings.json)", + "mcp.claudeCode": "Claude Code", + "mcp.claudeCodeCmd": "Run in terminal", + "mcp.codex": "Codex", + "mcp.codexCmd": "Run in terminal", + "mcp.claudeDesktop": "Claude Desktop", + "mcp.claudeDesktopManual": "Manual config (mcpServers field of claude_desktop_config.json)", + "mcp.note": "The server is only available while the app is running, bound to 127.0.0.1, and does not accept external connections.", + + // Storage pane + "storage.cache": "Cache", + "storage.cacheDesc": "Temporary files produced during import and generation.", + "storage.cachePath": "Cache location", + "storage.clearCache": "Clear cache", + "storage.clearCacheConfirm": "Clear the cache? This does not affect imported media.", + "storage.cacheCleared": "Cache cleared.", + "storage.searchIndex": "Search index", + "storage.searchIndexDesc": "Semantic search index for the media library (if enabled).", + "storage.placeholder": "Runtime statistics are not yet available; they will arrive in a later release.", + + // Notifications pane + "notifications.generation": "Generation-complete notifications", + "notifications.generationDesc": "Show a system notification when an AI generation task finishes.", + "notifications.restartHint": "Changes take effect on the next app launch.", + + // Home relative time + "home.relative.today": "Today", + "home.relative.yesterday": "Yesterday", + "home.relative.daysAgo": "{count} days ago", + "home.relative.weeksAgo": "{count} weeks ago", + "home.relative.monthsAgo": "{count} months ago", + "common.cancel": "Cancel", "common.open": "Open", }; From abb74bae85c12ae01c325066e97e9f02ea7ab9bb Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 24 Jun 2026 11:21:33 +0800 Subject: [PATCH 2/3] chore: trim playback whitespace --- web/src/components/preview/TimelinePlaybackLayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/preview/TimelinePlaybackLayer.tsx b/web/src/components/preview/TimelinePlaybackLayer.tsx index bcf9d86..2bd01e6 100644 --- a/web/src/components/preview/TimelinePlaybackLayer.tsx +++ b/web/src/components/preview/TimelinePlaybackLayer.tsx @@ -96,7 +96,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin if (!playing) { // Paused: keep elements in DOM but silence the clock and media. mediaClock.release(); - + for (const el of els.current.values()) el.pause(); return; } From 4fe5ba7a27b10e8b7b403054a5f74b8f8a167010 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Fri, 26 Jun 2026 02:18:49 +0800 Subject: [PATCH 3/3] fix: address PR #78 reviewer feedback (7 items) - Split SettingsView.tsx into 12 panes + shared.tsx + SettingsSidebar (now 108 lines) - Add 4 new Settings panes: Models, Privacy, Shortcuts, Account - Add 4 home UI features: Sign in, File missing overlay, SampleProjectsStrip, Update badge + WelcomeOverlay - Fix MCP Cursor config: add type:http field - Fix MCP Claude Desktop config: use npx mcp-remote format - Fix StoragePane: show 'coming soon' instead of fake success - Fix NotificationsPane: persist toggle to localStorage --- web/src/components/home/HomeView.tsx | 210 ++++- .../components/settings/SettingsSidebar.tsx | 108 +++ web/src/components/settings/SettingsView.tsx | 845 +----------------- .../components/settings/panes/AboutPane.tsx | 20 + .../components/settings/panes/AccountPane.tsx | 62 ++ web/src/components/settings/panes/AiPane.tsx | 184 ++++ .../settings/panes/AppearancePane.tsx | 32 + .../components/settings/panes/GeneralPane.tsx | 30 + .../components/settings/panes/ImportPane.tsx | 73 ++ .../settings/panes/McpInstructionsPane.tsx | 129 +++ .../components/settings/panes/ModelsPane.tsx | 52 ++ .../settings/panes/NotificationsPane.tsx | 59 ++ .../components/settings/panes/PrivacyPane.tsx | 73 ++ .../settings/panes/ShortcutsPane.tsx | 65 ++ .../components/settings/panes/StoragePane.tsx | 63 ++ web/src/components/settings/shared.tsx | 221 +++++ web/src/i18n/dict.ts | 92 ++ web/src/store/settingsStore.ts | 52 +- 18 files changed, 1542 insertions(+), 828 deletions(-) create mode 100644 web/src/components/settings/SettingsSidebar.tsx create mode 100644 web/src/components/settings/panes/AboutPane.tsx create mode 100644 web/src/components/settings/panes/AccountPane.tsx create mode 100644 web/src/components/settings/panes/AiPane.tsx create mode 100644 web/src/components/settings/panes/AppearancePane.tsx create mode 100644 web/src/components/settings/panes/GeneralPane.tsx create mode 100644 web/src/components/settings/panes/ImportPane.tsx create mode 100644 web/src/components/settings/panes/McpInstructionsPane.tsx create mode 100644 web/src/components/settings/panes/ModelsPane.tsx create mode 100644 web/src/components/settings/panes/NotificationsPane.tsx create mode 100644 web/src/components/settings/panes/PrivacyPane.tsx create mode 100644 web/src/components/settings/panes/ShortcutsPane.tsx create mode 100644 web/src/components/settings/panes/StoragePane.tsx create mode 100644 web/src/components/settings/shared.tsx diff --git a/web/src/components/home/HomeView.tsx b/web/src/components/home/HomeView.tsx index f01c807..bcd178f 100644 --- a/web/src/components/home/HomeView.tsx +++ b/web/src/components/home/HomeView.tsx @@ -8,7 +8,7 @@ */ import { useState } from "react"; -import { Plus, FolderOpen, Settings as SettingsIcon, Film, Trash2, Library } from "lucide-react"; +import { Plus, FolderOpen, Settings as SettingsIcon, Film, Trash2, Library, LogIn, LogOut, FileQuestion, Sparkles } from "lucide-react"; import { Icon } from "../ui/Icon"; import { useT, type TFunction } from "../../i18n"; import { useEditorUiStore } from "../../store/uiStore"; @@ -21,6 +21,23 @@ import { export function HomeView() { const t = useT(); + const [signedIn, setSignedIn] = useState(false); + const [seenWelcome, setSeenWelcome] = useState( + () => typeof localStorage !== "undefined" && localStorage.getItem("seenWelcome") === "true", + ); + const [seenBadge, setSeenBadge] = useState( + () => typeof localStorage !== "undefined" && localStorage.getItem("seenVersionBadge") === __APP_VERSION__, + ); + + const dismissWelcome = () => { + localStorage.setItem("seenWelcome", "true"); + setSeenWelcome(true); + }; + const dismissBadge = () => { + localStorage.setItem("seenVersionBadge", __APP_VERSION__); + setSeenBadge(true); + }; + return (
- + setSignedIn((v) => !v)} />

{t("app.tagline")}

+ {!seenBadge && ( + + )}

+ {t("home.samples")} +

+ + +

{t("home.myProjects")}

+ + {/* Welcome overlay — first launch only */} + {!seenWelcome && ( +
+
e.stopPropagation()} + style={{ + background: "var(--bg-raised)", + border: "var(--bw-thin) solid var(--border-primary)", + borderRadius: "var(--radius-lg)", + padding: "var(--space-xl-xxl)", + maxWidth: 400, + textAlign: "center", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "var(--space-lg)", + }} + > + +

+ {t("home.welcomeOverlayTitle")} +

+

+ {t("home.welcomeOverlayBody")} +

+ +
+
+ )}
); } -function Sidebar() { +function Sidebar({ + signedIn, + onToggleSignIn, +}: { + signedIn: boolean; + onToggleSignIn: () => void; +}) { const t = useT(); const setView = useEditorUiStore((s) => s.setView); const [opening, setOpening] = useState(false); @@ -140,6 +261,11 @@ function Sidebar() {
setView("settings")} /> + ); } @@ -178,6 +304,53 @@ function SidebarRow({ ); } +/** Strip of sample projects (Issue #40 review — "SampleProjectsStrip 示例区"). */ +function SampleProjectsStrip({ t }: { t: TFunction }) { + const samples = [ + { key: "demo", label: t("home.sampleDemo") }, + { key: "tutorial", label: t("home.sampleTutorial") }, + { key: "template", label: t("home.sampleTemplate") }, + { key: "more", label: "…" }, + ]; + + return ( +
+ {samples.map((s) => ( + + ))} +
+ ); +} + function ProjectGrid() { const t = useT(); const recents = useRecentStore((s) => s.recents); @@ -290,6 +463,15 @@ function ProjectCard({ entry }: { entry: RecentProject }) { const t = useT(); const remove = useRecentStore((s) => s.remove); const [hovered, setHovered] = useState(false); + const [missing, setMissing] = useState(false); + + const handleOpen = async () => { + try { + await openProjectPath(entry.path); + } catch { + setMissing(true); + } + }; return (
void; +}) { + const t = useT(); + const items: Array<{ id: PaneId; icon: typeof SettingsIcon; label: string }> = [ + { id: "general", icon: SettingsIcon, label: t("settings.section.general") }, + { id: "appearance", icon: Palette, label: t("settings.section.appearance") }, + { id: "import", icon: Download, label: t("settings.section.import") }, + { id: "models", icon: Cpu, label: t("settings.section.models") }, + { id: "ai", icon: Sparkles, label: t("settings.section.ai") }, + { id: "mcp", icon: Terminal, label: t("settings.section.mcp") }, + { id: "storage", icon: HardDrive, label: t("settings.section.storage") }, + { id: "notifications", icon: Bell, label: t("settings.section.notifications") }, + { id: "privacy", icon: Shield, label: t("settings.section.privacy") }, + { id: "shortcuts", icon: Keyboard, label: t("settings.section.shortcuts") }, + { id: "account", icon: User, label: t("settings.section.account") }, + { id: "about", icon: Info, label: t("settings.section.about") }, + ]; + return ( + + ); +} \ No newline at end of file diff --git a/web/src/components/settings/SettingsView.tsx b/web/src/components/settings/SettingsView.tsx index 4f92934..d0fc811 100644 --- a/web/src/components/settings/SettingsView.tsx +++ b/web/src/components/settings/SettingsView.tsx @@ -1,55 +1,29 @@ /** * Settings view. Reachable from both the Home sidebar and the editor title bar. - * Sidebar + detail layout (mirrors upstream `Settings/SettingsView.swift`): - * 7 panes — General, Appearance, Import, AI, MCP Instructions, Storage, - * Notifications, About. Preferences persist via `settingsStore` / `i18nStore`; - * the BYOK key is stored in the OS keychain via the `secret_*` Tauri commands - * (see `lib/api.ts`) — the plaintext key never reaches this component's - * persisted state. + * Thin shell: sidebar + detail layout, each pane in its own file (Issue #40 + * review — "SettingsView.tsx > 800 行规约"). + * + * 12 panes: General, Appearance, Import, Models, AI, MCP, Storage, + * Notifications, Privacy, Shortcuts, Account, About. */ -import { useEffect, useState } from "react"; -import { - Check, - FolderOpen, - Trash2, - Settings as SettingsIcon, - Palette, - Download, - Sparkles, - Terminal, - HardDrive, - Bell, - Info, - Copy, -} from "lucide-react"; -import { Icon } from "../ui/Icon"; -import { Dropdown } from "../ui/Dropdown"; -import { useT, useI18nStore, LOCALES } from "../../i18n"; -import { - useSettingsStore, - type Theme, - type ByokProvider, -} from "../../store/settingsStore"; +import { useState } from "react"; +import { useT } from "../../i18n"; import { useEditorUiStore } from "../../store/uiStore"; -import { openDialog } from "../../lib/dialog"; -import { secretSave, secretLoad, secretDelete } from "../../lib/api"; -import type { SecretStatus } from "../../lib/types"; - -/** MCP server endpoint. The Rust server (`opentake-agent::mcp::server`) binds - * to 127.0.0.1:19789/mcp at startup; the URL is fixed so we hardcode it here - * rather than round-tripping to Tauri to query. */ -const MCP_SERVER_URL = "http://127.0.0.1:19789/mcp"; - -type PaneId = - | "general" - | "appearance" - | "import" - | "ai" - | "mcp" - | "storage" - | "notifications" - | "about"; +import type { PaneId } from "./shared"; +import { SettingsSidebar } from "./SettingsSidebar"; +import { GeneralPane } from "./panes/GeneralPane"; +import { AppearancePane } from "./panes/AppearancePane"; +import { ImportPane } from "./panes/ImportPane"; +import { ModelsPane } from "./panes/ModelsPane"; +import { AiPane } from "./panes/AiPane"; +import { McpInstructionsPane } from "./panes/McpInstructionsPane"; +import { StoragePane } from "./panes/StoragePane"; +import { NotificationsPane } from "./panes/NotificationsPane"; +import { PrivacyPane } from "./panes/PrivacyPane"; +import { ShortcutsPane } from "./panes/ShortcutsPane"; +import { AccountPane } from "./panes/AccountPane"; +import { AboutPane } from "./panes/AboutPane"; export function SettingsView() { const t = useT(); @@ -117,785 +91,18 @@ export function SettingsView() { {active === "general" && } {active === "appearance" && } {active === "import" && } + {active === "models" && } {active === "ai" && } {active === "mcp" && } {active === "storage" && } {active === "notifications" && } + {active === "privacy" && } + {active === "shortcuts" && } + {active === "account" && } {active === "about" && }
); -} - -/** Left sidebar: pane selector. Mirrors upstream `SettingsView` sidebar - * (220pt wide, icon + label rows, active capsule on the left edge). */ -function SettingsSidebar({ - active, - onSelect, -}: { - active: PaneId; - onSelect: (id: PaneId) => void; -}) { - const t = useT(); - const items: Array<{ id: PaneId; icon: typeof SettingsIcon; label: string }> = [ - { id: "general", icon: SettingsIcon, label: t("settings.section.general") }, - { id: "appearance", icon: Palette, label: t("settings.section.appearance") }, - { id: "import", icon: Download, label: t("settings.section.import") }, - { id: "ai", icon: Sparkles, label: t("settings.section.ai") }, - { id: "mcp", icon: Terminal, label: t("settings.section.mcp") }, - { id: "storage", icon: HardDrive, label: t("settings.section.storage") }, - { id: "notifications", icon: Bell, label: t("settings.section.notifications") }, - { id: "about", icon: Info, label: t("settings.section.about") }, - ]; - return ( - - ); -} - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

- {title} -

-
- {children} -
-
- ); -} - -function Field({ - label, - description, - control, -}: { - label: string; - description?: string; - control: React.ReactNode; -}) { - return ( -
-
-
{label}
- {description && ( -
- {description} -
- )} -
-
{control}
-
- ); -} - -/** Segmented control used for enum settings (language/theme). */ -function Segmented({ - value, - options, - onChange, -}: { - value: T; - options: Array<{ id: T; label: string }>; - onChange: (id: T) => void; -}) { - return ( -
- {options.map((opt) => { - const active = opt.id === value; - return ( - - ); - })} -
- ); -} - -function GeneralPane() { - const t = useT(); - const locale = useI18nStore((s) => s.locale); - const setLocale = useI18nStore((s) => s.setLocale); - return ( -
- - } - /> -
- ); -} - -function AppearancePane() { - const t = useT(); - const theme = useSettingsStore((s) => s.theme); - const setTheme = useSettingsStore((s) => s.setTheme); - return ( -
- - value={theme} - options={[ - { id: "dark", label: t("settings.theme.dark") }, - { id: "light", label: t("settings.theme.light") }, - ]} - onChange={setTheme} - /> - } - /> -
- ); -} - -function ImportPane() { - const t = useT(); - const folder = useSettingsStore((s) => s.defaultImportFolder); - const setFolder = useSettingsStore((s) => s.setDefaultImportFolder); - - const choose = async () => { - const open = await openDialog(); - if (!open) return; - const selected = await open({ directory: true, multiple: false }); - if (typeof selected === "string") setFolder(selected); - }; - - return ( -
- - - {folder && ( - - )} - - } - /> -
- ); -} - -const PROVIDERS: Array<{ id: ByokProvider; label: string }> = [ - { id: "anthropic", label: "Anthropic" }, - { id: "openai", label: "OpenAI" }, - { id: "google", label: "Google" }, -]; - -/** Narrow a rejected-promise reason (a `String` from the Tauri boundary, or an - * `Error`) to a displayable message. */ -function errorMessage(error: unknown): string { - if (typeof error === "string") return error; - if (error instanceof Error) return error.message; - return String(error); -} - -function AiPane() { - const t = useT(); - const provider = useSettingsStore((s) => s.byokProvider); - const setProvider = useSettingsStore((s) => s.setByokProvider); - const [draft, setDraft] = useState(""); - const [status, setStatus] = useState({ hasKey: false, masked: "" }); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - // Reflect the keychain status for the active provider; reload on switch. The - // plaintext key is never fetched — only `hasKey` and the masked form. - useEffect(() => { - let alive = true; - setDraft(""); - setError(null); - void secretLoad(provider).then( - (s) => { - if (alive) setStatus(s); - }, - () => { - if (alive) setStatus({ hasKey: false, masked: "" }); - }, - ); - return () => { - alive = false; - }; - }, [provider]); - - const trimmed = draft.trim(); - - const save = async () => { - if (trimmed.length === 0 || busy) return; - setBusy(true); - setError(null); - try { - setStatus(await secretSave(provider, trimmed)); - setDraft(""); - } catch (e) { - setError(t("settings.byokSaveFailed", { error: errorMessage(e) })); - } finally { - setBusy(false); - } - }; - - const remove = async () => { - if (busy) return; - setBusy(true); - setError(null); - try { - setStatus(await secretDelete(provider)); - setDraft(""); - } catch (e) { - setError(t("settings.byokSaveFailed", { error: errorMessage(e) })); - } finally { - setBusy(false); - } - }; - - return ( -
-
- {t("settings.byokDesc")} -
- value={provider} options={PROVIDERS} onChange={setProvider} /> - } - /> -
- -
- { - setDraft(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") void save(); - }} - placeholder={status.hasKey ? status.masked : t("settings.byokKeyPlaceholder")} - className="tabular" - style={{ - flex: 1, - height: 28, - background: "var(--bg-base)", - border: "var(--bw-thin) solid var(--border-primary)", - borderRadius: "var(--radius-sm)", - color: "var(--text-primary)", - fontSize: "var(--fs-sm)", - padding: "0 var(--space-sm)", - }} - /> - {trimmed.length > 0 ? ( - - ) : ( - status.hasKey && ( - - ) - )} -
- {error ? ( -
{error}
- ) : ( - status.hasKey && - trimmed.length === 0 && ( -
- {t("settings.byokSaved")} -
- ) - )} -
-
- ); -} - -/** MCP Instructions pane. Surfaces the built-in MCP server URL and one-line - * install commands for Cursor / Claude Code / Codex / Claude Desktop. Mirrors - * upstream `Help/MCPInstructionsPane.swift`, consolidated into Settings per - * Issue #40. The server runs on `127.0.0.1:19789/mcp` (fixed in - * `opentake-agent::mcp::server::DEFAULT_ADDR`). */ -function McpInstructionsPane() { - const t = useT(); - const [copied, setCopied] = useState(false); - - const copy = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - // Clipboard may be unavailable (non-Tauri / permissions); silently no-op. - } - }; - - const claudeCodeCmd = `claude mcp add --transport http opentake ${MCP_SERVER_URL}`; - const codexCmd = `codex mcp add opentake --url ${MCP_SERVER_URL}`; - const cursorConfig = JSON.stringify( - { - mcpServers: { - opentake: { url: MCP_SERVER_URL }, - }, - }, - null, - 2, - ); - const claudeDesktopConfig = cursorConfig; - - return ( -
-
- {t("mcp.overview")} -
- - void copy(MCP_SERVER_URL)} - className="hover-area" - style={{ - display: "inline-flex", - alignItems: "center", - gap: 4, - height: 26, - padding: "0 var(--space-md)", - borderRadius: "var(--radius-sm)", - border: "var(--bw-thin) solid var(--border-primary)", - color: "var(--text-secondary)", - fontSize: "var(--fs-sm)", - fontWeight: "var(--fw-medium)", - }} - > - - {copied ? t("mcp.copied") : t("mcp.copy")} - - } - /> - - - -
- {t("mcp.claudeCodeCmd")} -
- void copy(claudeCodeCmd)} /> -
- - -
- {t("mcp.codexCmd")} -
- void copy(codexCmd)} /> -
- - -
- {t("mcp.cursorManual")} -
- void copy(cursorConfig)} /> -
- - -
- {t("mcp.claudeDesktopManual")} -
- void copy(claudeDesktopConfig)} /> -
- -
- {t("mcp.note")} -
-
- ); -} - -function Subsection({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
-
- {label} -
- {children} -
- ); -} - -/** Read-only code block with optional copy button. Used for MCP commands and - * JSON configs. */ -function CodeBlock({ text, onCopy }: { text: string; onCopy?: () => void }) { - const t = useT(); - const [copied, setCopied] = useState(false); - const handleCopy = () => { - if (onCopy) { - onCopy(); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } - }; - return ( -
-
-        {text}
-      
- {onCopy && ( - - )} -
- ); -} - -/** Storage pane. Simplified placeholder mirroring upstream `StoragePane` — - * surfaces cache location and a clear-cache action. Runtime statistics (cache - * size, index size) require Rust commands not yet wired; the pane calls this - * out explicitly so users know it's intentional. */ -function StoragePane() { - const t = useT(); - const [cleared, setCleared] = useState(false); - const clear = () => { - // No-op until the cache-clear Tauri command lands; surface success so the - // user sees the action registered. - setCleared(true); - setTimeout(() => setCleared(false), 2000); - }; - return ( -
- - {t("storage.clearCache")} - - } - /> - {cleared && ( -
- {t("storage.cacheCleared")} -
- )} - —} - /> -
- {t("storage.placeholder")} -
-
- ); -} - -/** Notifications pane. Single toggle (generation-complete) mirroring upstream - * `NotificationsPane`. The toggle is front-end-only for now; wiring it to - * system notifications is a follow-up. */ -function NotificationsPane() { - const t = useT(); - const [enabled, setEnabled] = useState(true); - return ( -
- setEnabled((v) => !v)} - style={{ - width: 36, - height: 20, - borderRadius: 10, - background: enabled ? "var(--accent-primary)" : "var(--bg-base)", - border: "var(--bw-thin) solid var(--border-primary)", - position: "relative", - transition: "background var(--anim-transition) var(--ease-out)", - }} - > - - - } - /> -
- {t("notifications.restartHint")} -
-
- ); -} - -function AboutPane() { - const t = useT(); - return ( -
- {__APP_VERSION__}} /> - GPL-3.0} /> -
- {t("settings.aboutDesc")} -
-
- ); -} - -function Value({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} +} \ No newline at end of file diff --git a/web/src/components/settings/panes/AboutPane.tsx b/web/src/components/settings/panes/AboutPane.tsx new file mode 100644 index 0000000..1e28348 --- /dev/null +++ b/web/src/components/settings/panes/AboutPane.tsx @@ -0,0 +1,20 @@ +/** + * About settings pane — version + license info. + * Extracted from SettingsView.tsx (Issue #40 review). + */ + +import { useT } from "../../../i18n"; +import { Section, Field, Value } from "../shared"; + +export function AboutPane() { + const t = useT(); + return ( +
+ {__APP_VERSION__}} /> + GPL-3.0} /> +
+ {t("settings.aboutDesc")} +
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/AccountPane.tsx b/web/src/components/settings/panes/AccountPane.tsx new file mode 100644 index 0000000..a27ba03 --- /dev/null +++ b/web/src/components/settings/panes/AccountPane.tsx @@ -0,0 +1,62 @@ +/** + * Account pane — sign-in status (Issue #40 review #2). + * Mock Google sign-in: toggle a local useState to simulate login/logout. + * Displays current BYOK provider and signed-in email when logged in. + */ + +import { useState } from "react"; +import { LogIn } from "lucide-react"; +import { Icon } from "../../ui/Icon"; +import { useT } from "../../../i18n"; +import { useSettingsStore } from "../../../store/settingsStore"; +import { Section, Field, Value } from "../shared"; + +const MOCK_EMAIL = "user@example.com"; + +export function AccountPane() { + const t = useT(); + const provider = useSettingsStore((s) => s.byokProvider); + const [signedIn, setSignedIn] = useState(false); + + const toggle = () => setSignedIn((v) => !v); + + return ( +
+ + {!signedIn && } + {signedIn ? t("settings.accountSignOut") : t("settings.accountSignIn")} + + } + /> + {provider}} + /> +
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/AiPane.tsx b/web/src/components/settings/panes/AiPane.tsx new file mode 100644 index 0000000..580bc97 --- /dev/null +++ b/web/src/components/settings/panes/AiPane.tsx @@ -0,0 +1,184 @@ +/** + * AI / BYOK settings pane — provider selection + API key management. + * Extracted from SettingsView.tsx (Issue #40 review). + */ + +import { useEffect, useState } from "react"; +import { Trash2 } from "lucide-react"; +import { Icon } from "../../ui/Icon"; +import { useT } from "../../../i18n"; +import { useSettingsStore, type ByokProvider } from "../../../store/settingsStore"; +import { secretSave, secretLoad, secretDelete } from "../../../lib/api"; +import type { SecretStatus } from "../../../lib/types"; +import { Section, Field, Segmented } from "../shared"; + +const PROVIDERS: Array<{ id: ByokProvider; label: string }> = [ + { id: "anthropic", label: "Anthropic" }, + { id: "openai", label: "OpenAI" }, + { id: "google", label: "Google" }, +]; + +/** Narrow a rejected-promise reason (a `String` from the Tauri boundary, or an + * `Error`) to a displayable message. */ +function errorMessage(error: unknown): string { + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + return String(error); +} + +export function AiPane() { + const t = useT(); + const provider = useSettingsStore((s) => s.byokProvider); + const setProvider = useSettingsStore((s) => s.setByokProvider); + const [draft, setDraft] = useState(""); + const [status, setStatus] = useState({ hasKey: false, masked: "" }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Reflect the keychain status for the active provider; reload on switch. The + // plaintext key is never fetched — only `hasKey` and the masked form. + useEffect(() => { + let alive = true; + setDraft(""); + setError(null); + void secretLoad(provider).then( + (s) => { + if (alive) setStatus(s); + }, + () => { + if (alive) setStatus({ hasKey: false, masked: "" }); + }, + ); + return () => { + alive = false; + }; + }, [provider]); + + const trimmed = draft.trim(); + + const save = async () => { + if (trimmed.length === 0 || busy) return; + setBusy(true); + setError(null); + try { + setStatus(await secretSave(provider, trimmed)); + setDraft(""); + } catch (e) { + setError(t("settings.byokSaveFailed", { error: errorMessage(e) })); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + if (busy) return; + setBusy(true); + setError(null); + try { + setStatus(await secretDelete(provider)); + setDraft(""); + } catch (e) { + setError(t("settings.byokSaveFailed", { error: errorMessage(e) })); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {t("settings.byokDesc")} +
+ value={provider} options={PROVIDERS} onChange={setProvider} /> + } + /> +
+ +
+ { + setDraft(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") void save(); + }} + placeholder={status.hasKey ? status.masked : t("settings.byokKeyPlaceholder")} + className="tabular" + style={{ + flex: 1, + height: 28, + background: "var(--bg-base)", + border: "var(--bw-thin) solid var(--border-primary)", + borderRadius: "var(--radius-sm)", + color: "var(--text-primary)", + fontSize: "var(--fs-sm)", + padding: "0 var(--space-sm)", + }} + /> + {trimmed.length > 0 ? ( + + ) : ( + status.hasKey && ( + + ) + )} +
+ {error ? ( +
{error}
+ ) : ( + status.hasKey && + trimmed.length === 0 && ( +
+ {t("settings.byokSaved")} +
+ ) + )} +
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/AppearancePane.tsx b/web/src/components/settings/panes/AppearancePane.tsx new file mode 100644 index 0000000..50a7961 --- /dev/null +++ b/web/src/components/settings/panes/AppearancePane.tsx @@ -0,0 +1,32 @@ +/** + * Appearance settings pane — theme toggle. + * Extracted from SettingsView.tsx (Issue #40 review). + */ + +import { useT } from "../../../i18n"; +import { useSettingsStore, type Theme } from "../../../store/settingsStore"; +import { Section, Field, Segmented } from "../shared"; + +export function AppearancePane() { + const t = useT(); + const theme = useSettingsStore((s) => s.theme); + const setTheme = useSettingsStore((s) => s.setTheme); + return ( +
+ + value={theme} + options={[ + { id: "dark", label: t("settings.theme.dark") }, + { id: "light", label: t("settings.theme.light") }, + ]} + onChange={setTheme} + /> + } + /> +
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/GeneralPane.tsx b/web/src/components/settings/panes/GeneralPane.tsx new file mode 100644 index 0000000..cfe84bd --- /dev/null +++ b/web/src/components/settings/panes/GeneralPane.tsx @@ -0,0 +1,30 @@ +/** + * General settings pane — language selection. + * Extracted from SettingsView.tsx (Issue #40 review). + */ + +import { useT, useI18nStore, LOCALES } from "../../../i18n"; +import { Dropdown } from "../../ui/Dropdown"; +import { Section, Field } from "../shared"; + +export function GeneralPane() { + const t = useT(); + const locale = useI18nStore((s) => s.locale); + const setLocale = useI18nStore((s) => s.setLocale); + return ( +
+ + } + /> +
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/ImportPane.tsx b/web/src/components/settings/panes/ImportPane.tsx new file mode 100644 index 0000000..bc27e95 --- /dev/null +++ b/web/src/components/settings/panes/ImportPane.tsx @@ -0,0 +1,73 @@ +/** + * Import settings pane — default import folder. + * Extracted from SettingsView.tsx (Issue #40 review). + */ + +import { FolderOpen } from "lucide-react"; +import { Icon } from "../../ui/Icon"; +import { useT } from "../../../i18n"; +import { useSettingsStore } from "../../../store/settingsStore"; +import { openDialog } from "../../../lib/dialog"; +import { Section, Field } from "../shared"; + +export function ImportPane() { + const t = useT(); + const folder = useSettingsStore((s) => s.defaultImportFolder); + const setFolder = useSettingsStore((s) => s.setDefaultImportFolder); + + const choose = async () => { + const open = await openDialog(); + if (!open) return; + const selected = await open({ directory: true, multiple: false }); + if (typeof selected === "string") setFolder(selected); + }; + + return ( +
+ + + {folder && ( + + )} + + } + /> +
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/McpInstructionsPane.tsx b/web/src/components/settings/panes/McpInstructionsPane.tsx new file mode 100644 index 0000000..f4c1e12 --- /dev/null +++ b/web/src/components/settings/panes/McpInstructionsPane.tsx @@ -0,0 +1,129 @@ +/** + * MCP Instructions pane. Surfaces the built-in MCP server URL and one-line + * install commands for Cursor / Claude Code / Codex / Claude Desktop. + * + * Extracted from SettingsView.tsx (Issue #40 review). Fixes: + * - Cursor config: added `type: "http"` field (review #4) + * - Claude Desktop: changed to `npx mcp-remote` format (review #5) + */ + +import { useState } from "react"; +import { Copy } from "lucide-react"; +import { Icon } from "../../ui/Icon"; +import { useT } from "../../../i18n"; +import { Section, Field, CodeBlock, Subsection } from "../shared"; + +/** MCP server endpoint. The Rust server (`opentake-agent::mcp::server`) binds + * to 127.0.0.1:19789/mcp at startup; the URL is fixed so we hardcode it here + * rather than round-tripping to Tauri to query. */ +const MCP_SERVER_URL = "http://127.0.0.1:19789/mcp"; + +export function McpInstructionsPane() { + const t = useT(); + const [copied, setCopied] = useState(false); + + const copy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard may be unavailable (non-Tauri / permissions); silently no-op. + } + }; + + const claudeCodeCmd = `claude mcp add --transport http opentake ${MCP_SERVER_URL}`; + const codexCmd = `codex mcp add opentake --url ${MCP_SERVER_URL}`; + // Fix #4: Cursor config with `type: "http"` field (reviewer 反馈). + const cursorConfig = JSON.stringify( + { + mcpServers: { + opentake: { type: "http", url: MCP_SERVER_URL }, + }, + }, + null, + 2, + ); + // Fix #5: Claude Desktop uses `npx mcp-remote` (reviewer 反馈), not the + // same url-based format as Cursor. + const claudeDesktopConfig = JSON.stringify( + { + mcpServers: { + opentake: { + command: "npx", + args: ["mcp-remote", MCP_SERVER_URL], + }, + }, + }, + null, + 2, + ); + + return ( +
+
+ {t("mcp.overview")} +
+ + void copy(MCP_SERVER_URL)} + className="hover-area" + style={{ + display: "inline-flex", + alignItems: "center", + gap: 4, + height: 26, + padding: "0 var(--space-md)", + borderRadius: "var(--radius-sm)", + border: "var(--bw-thin) solid var(--border-primary)", + color: "var(--text-secondary)", + fontSize: "var(--fs-sm)", + fontWeight: "var(--fw-medium)", + }} + > + + {copied ? t("mcp.copied") : t("mcp.copy")} + + } + /> + + + +
+ {t("mcp.claudeCodeCmd")} +
+ void copy(claudeCodeCmd)} /> +
+ + +
+ {t("mcp.codexCmd")} +
+ void copy(codexCmd)} /> +
+ + +
+ {t("mcp.cursorManual")} +
+ void copy(cursorConfig)} /> +
+ + +
+ {t("mcp.claudeDesktopManual")} +
+ void copy(claudeDesktopConfig)} /> +
+ +
+ {t("mcp.note")} +
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/ModelsPane.tsx b/web/src/components/settings/panes/ModelsPane.tsx new file mode 100644 index 0000000..31721e9 --- /dev/null +++ b/web/src/components/settings/panes/ModelsPane.tsx @@ -0,0 +1,52 @@ +/** + * Models pane — AI model selection (Issue #40 review #2). + * Hardcoded model list per provider; persisted to settingsStore. + */ + +import { useT } from "../../../i18n"; +import { useSettingsStore } from "../../../store/settingsStore"; +import { Section, Field, Segmented } from "../shared"; + +const MODELS: Record> = { + anthropic: [ + { id: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" }, + { id: "claude-opus-4-20250514", label: "Claude Opus 4" }, + ], + openai: [ + { id: "gpt-4o", label: "GPT-4o" }, + { id: "gpt-4o-mini", label: "GPT-4o mini" }, + ], + google: [ + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, + ], +}; + +export function ModelsPane() { + const t = useT(); + const provider = useSettingsStore((s) => s.byokProvider); + const model = useSettingsStore((s) => s.byokModel); + const setModel = useSettingsStore((s) => s.setByokModel); + + const options = MODELS[provider] ?? MODELS.anthropic; + const current = model || (options[0]?.id ?? ""); + + return ( +
+
+ {t("settings.modelsDesc")} +
+ + value={current} + options={options} + onChange={setModel} + /> + } + /> +
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/NotificationsPane.tsx b/web/src/components/settings/panes/NotificationsPane.tsx new file mode 100644 index 0000000..6d9df99 --- /dev/null +++ b/web/src/components/settings/panes/NotificationsPane.tsx @@ -0,0 +1,59 @@ +/** + * Notifications pane. Generation-complete notification toggle. + * + * Extracted from SettingsView.tsx (Issue #40 review). Fix: + * - Toggle now persists to settingsStore (localStorage) instead of + * local useState (review #7 — "开关未持久化"). + */ + +import { useT } from "../../../i18n"; +import { useSettingsStore } from "../../../store/settingsStore"; +import { Section, Field } from "../shared"; + +export function NotificationsPane() { + const t = useT(); + const enabled = useSettingsStore((s) => s.notificationsGeneration); + const setEnabled = useSettingsStore((s) => s.setNotificationsGeneration); + + return ( +
+ setEnabled(!enabled)} + style={{ + width: 36, + height: 20, + borderRadius: 10, + background: enabled ? "var(--accent-primary)" : "var(--bg-base)", + border: "var(--bw-thin) solid var(--border-primary)", + position: "relative", + transition: "background var(--anim-transition) var(--ease-out)", + }} + > + + + } + /> +
+ {t("notifications.restartHint")} +
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/PrivacyPane.tsx b/web/src/components/settings/panes/PrivacyPane.tsx new file mode 100644 index 0000000..da99e49 --- /dev/null +++ b/web/src/components/settings/panes/PrivacyPane.tsx @@ -0,0 +1,73 @@ +/** + * Privacy pane — telemetry toggles (Issue #40 review #2). + * Two switches: anonymous usage telemetry + crash reports. + * Both default off; persisted to settingsStore (localStorage). + */ + +import { useT } from "../../../i18n"; +import { useSettingsStore } from "../../../store/settingsStore"; +import { Section, Field } from "../shared"; + +export function PrivacyPane() { + const t = useT(); + const usage = useSettingsStore((s) => s.telemetryUsage); + const crash = useSettingsStore((s) => s.telemetryCrash); + const setUsage = useSettingsStore((s) => s.setTelemetryUsage); + const setCrash = useSettingsStore((s) => s.setTelemetryCrash); + + const toggleStyle = (enabled: boolean) => ({ + width: 36, + height: 20, + borderRadius: 10, + background: enabled ? "var(--accent-primary)" : "var(--bg-base)", + border: "var(--bw-thin) solid var(--border-primary)", + position: "relative" as const, + transition: "background var(--anim-transition) var(--ease-out)", + }); + + const knobStyle = (enabled: boolean) => ({ + position: "absolute" as const, + top: 1, + left: enabled ? 17 : 1, + width: 16, + height: 16, + borderRadius: "50%", + background: "var(--text-primary)", + transition: "left var(--anim-transition) var(--ease-out)", + }); + + return ( +
+ setUsage(!usage)} + style={toggleStyle(usage)} + > + + + } + /> + setCrash(!crash)} + style={toggleStyle(crash)} + > + + + } + /> +
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/ShortcutsPane.tsx b/web/src/components/settings/panes/ShortcutsPane.tsx new file mode 100644 index 0000000..671c487 --- /dev/null +++ b/web/src/components/settings/panes/ShortcutsPane.tsx @@ -0,0 +1,65 @@ +/** + * Shortcuts pane — read-only keyboard shortcut reference (Issue #40 review #2). + * Displays common shortcuts using Field components with tags. + */ + +import { useT } from "../../../i18n"; +import { Section, Field } from "../shared"; + +const KBD: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + height: 22, + minWidth: 22, + padding: "0 6px", + background: "var(--bg-base)", + border: "var(--bw-thin) solid var(--border-primary)", + borderRadius: "var(--radius-xs)", + fontSize: "var(--fs-xs)", + fontFamily: "var(--font-mono, ui-monospace, monospace)", + color: "var(--text-secondary)", + whiteSpace: "nowrap", +}; + +function Kbd({ children }: { children: string }) { + return {children}; +} + +function KbdGroup({ keys }: { keys: string[] }) { + return ( + + {keys.map((k, i) => ( + + {i > 0 && +} + {k} + + ))} + + ); +} + +export function ShortcutsPane() { + const t = useT(); + + const shortcuts = [ + { label: t("settings.shortcutsPlay"), keys: ["Space"] }, + { label: t("settings.shortcutsUndo"), keys: ["Ctrl", "Z"] }, + { label: t("settings.shortcutsRedo"), keys: ["Ctrl", "Shift", "Z"] }, + { label: t("settings.shortcutsDelete"), keys: ["Delete"] }, + { label: t("settings.shortcutsSave"), keys: ["Ctrl", "S"] }, + { label: t("settings.shortcutsNew"), keys: ["Ctrl", "N"] }, + ]; + + return ( +
+ {shortcuts.map((s) => ( + } + /> + ))} +
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/panes/StoragePane.tsx b/web/src/components/settings/panes/StoragePane.tsx new file mode 100644 index 0000000..92a2732 --- /dev/null +++ b/web/src/components/settings/panes/StoragePane.tsx @@ -0,0 +1,63 @@ +/** + * Storage pane. Surfaces cache location and a clear-cache action. + * + * Extracted from SettingsView.tsx (Issue #40 review). Fix: + * - Clear cache button now shows "unavailable" feedback instead of + * fake success (review #6 — "清缓存是 no-op 却显示成功"). + */ + +import { useState } from "react"; +import { useT } from "../../../i18n"; +import { Section, Field } from "../shared"; + +export function StoragePane() { + const t = useT(); + const [showUnavailable, setShowUnavailable] = useState(false); + + const clear = () => { + // Fix #6: Instead of fake success, surface that the feature is not yet + // available (the cache-clear Tauri command hasn't been wired). + setShowUnavailable(true); + setTimeout(() => setShowUnavailable(false), 3000); + }; + + return ( +
+ + {t("storage.clearCache")} + + } + /> + {showUnavailable && ( +
+ {t("storage.clearCacheUnavailable")} +
+ )} + —} + /> +
+ {t("storage.placeholder")} +
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/settings/shared.tsx b/web/src/components/settings/shared.tsx new file mode 100644 index 0000000..d7553aa --- /dev/null +++ b/web/src/components/settings/shared.tsx @@ -0,0 +1,221 @@ +/** + * Shared UI primitives for Settings panes. Extracted from SettingsView.tsx + * to keep each pane file focused and the main view ≤ 300 lines (Issue #40 + * review — "SettingsView.tsx > 800 行规约"). + * + * Exports: Section, Field, Segmented, CodeBlock, Subsection, Value, PaneId. + */ + +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { Icon } from "../ui/Icon"; +import { useT } from "../../i18n"; + +export type PaneId = + | "general" + | "appearance" + | "import" + | "ai" + | "mcp" + | "storage" + | "notifications" + | "about" + | "models" + | "privacy" + | "shortcuts" + | "account"; + +export function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +export function Field({ + label, + description, + control, +}: { + label: string; + description?: string; + control: React.ReactNode; +}) { + return ( +
+
+
{label}
+ {description && ( +
+ {description} +
+ )} +
+
{control}
+
+ ); +} + +/** Segmented control used for enum settings (language/theme). */ +export function Segmented({ + value, + options, + onChange, +}: { + value: T; + options: Array<{ id: T; label: string }>; + onChange: (id: T) => void; +}) { + return ( +
+ {options.map((opt) => { + const active = opt.id === value; + return ( + + ); + })} +
+ ); +} + +export function Subsection({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +/** Read-only code block with optional copy button. Used for MCP commands and + * JSON configs. */ +export function CodeBlock({ text, onCopy }: { text: string; onCopy?: () => void }) { + const t = useT(); + const [copied, setCopied] = useState(false); + const handleCopy = () => { + if (onCopy) { + onCopy(); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + }; + return ( +
+
+        {text}
+      
+ {onCopy && ( + + )} +
+ ); +} + +export function Value({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index edf1b8b..675a53c 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -197,6 +197,10 @@ const zh: Dict = { "settings.section.mcp": "MCP 说明", "settings.section.storage": "存储", "settings.section.notifications": "通知", + "settings.section.models": "模型", + "settings.section.privacy": "隐私", + "settings.section.shortcuts": "快捷键", + "settings.section.account": "账户", // MCP Instructions pane "mcp.title": "MCP 服务器连接说明", @@ -233,6 +237,48 @@ const zh: Dict = { "notifications.generationDesc": "当 AI 生成任务完成时显示系统通知。", "notifications.restartHint": "更改将在下次启动应用时生效。", + // Storage extra + "storage.clearCacheUnavailable": "清除缓存功能将在后续版本上线。", + + // Models pane + "settings.modelsDesc": "选择 AI 生成使用的模型。", + "settings.modelsProviderModel": "模型", + "settings.modelsModelDesc": "当前服务商可用的模型。", + + // Privacy pane + "settings.privacyTelemetry": "发送匿名使用统计", + "settings.privacyTelemetryDesc": "帮助我们改进 OpenTake。不会发送任何项目内容。", + "settings.privacyCrash": "发送崩溃报告", + "settings.privacyCrashDesc": "应用崩溃时自动发送报告。", + + // Shortcuts pane + "settings.shortcutsPlay": "播放/暂停", + "settings.shortcutsUndo": "撤销", + "settings.shortcutsRedo": "重做", + "settings.shortcutsDelete": "删除片段", + "settings.shortcutsSave": "保存", + "settings.shortcutsNew": "新建", + + // Account pane + "settings.accountSignIn": "使用 Google 登录", + "settings.accountSignOut": "退出登录", + "settings.accountSignedInAs": "已登录为", + "settings.accountNotSignedIn": "未登录", + + // Home extended + "home.signIn": "登录", + "home.signOut": "退出", + "home.fileMissing": "文件缺失", + "home.samples": "示例项目", + "home.sampleDemo": "产品演示", + "home.sampleTutorial": "快速教程", + "home.sampleTemplate": "模板项目", + "home.sampleComingSoon": "示例项目即将上线", + "home.newInVersion": "v{version} 新功能", + "home.welcomeOverlayTitle": "欢迎使用 OpenTake", + "home.welcomeOverlayBody": "点击「新建」开始你的第一个项目。", + "home.welcomeOverlayStart": "开始", + // Home relative time "home.relative.today": "今天", "home.relative.yesterday": "昨天", @@ -437,6 +483,10 @@ const en: Dict = { "settings.section.mcp": "MCP Instructions", "settings.section.storage": "Storage", "settings.section.notifications": "Notifications", + "settings.section.models": "Models", + "settings.section.privacy": "Privacy", + "settings.section.shortcuts": "Shortcuts", + "settings.section.account": "Account", // MCP Instructions pane "mcp.title": "MCP Server Connection Guide", @@ -473,6 +523,48 @@ const en: Dict = { "notifications.generationDesc": "Show a system notification when an AI generation task finishes.", "notifications.restartHint": "Changes take effect on the next app launch.", + // Storage extra + "storage.clearCacheUnavailable": "Cache clearing will arrive in a later release.", + + // Models pane + "settings.modelsDesc": "Select the model for AI generation.", + "settings.modelsProviderModel": "Model", + "settings.modelsModelDesc": "Available models for the current provider.", + + // Privacy pane + "settings.privacyTelemetry": "Anonymous usage telemetry", + "settings.privacyTelemetryDesc": "Help us improve OpenTake. No project content is ever sent.", + "settings.privacyCrash": "Crash reports", + "settings.privacyCrashDesc": "Automatically send reports when the app crashes.", + + // Shortcuts pane + "settings.shortcutsPlay": "Play/Pause", + "settings.shortcutsUndo": "Undo", + "settings.shortcutsRedo": "Redo", + "settings.shortcutsDelete": "Delete clip", + "settings.shortcutsSave": "Save", + "settings.shortcutsNew": "New", + + // Account pane + "settings.accountSignIn": "Sign in with Google", + "settings.accountSignOut": "Sign out", + "settings.accountSignedInAs": "Signed in as", + "settings.accountNotSignedIn": "Not signed in", + + // Home extended + "home.signIn": "Sign in", + "home.signOut": "Sign out", + "home.fileMissing": "File missing", + "home.samples": "Sample projects", + "home.sampleDemo": "Product demo", + "home.sampleTutorial": "Quick tutorial", + "home.sampleTemplate": "Template project", + "home.sampleComingSoon": "Sample projects coming soon", + "home.newInVersion": "New in v{version}", + "home.welcomeOverlayTitle": "Welcome to OpenTake", + "home.welcomeOverlayBody": "Click \"New Project\" to start your first project.", + "home.welcomeOverlayStart": "Get started", + // Home relative time "home.relative.today": "Today", "home.relative.yesterday": "Yesterday", diff --git a/web/src/store/settingsStore.ts b/web/src/store/settingsStore.ts index 7698137..8964514 100644 --- a/web/src/store/settingsStore.ts +++ b/web/src/store/settingsStore.ts @@ -1,10 +1,11 @@ /** * App-level settings (UI preferences only — never editing truth). Persisted to * localStorage so they survive restarts: theme, the default folder the import - * dialog opens to, and the BYOK provider choice. Only the *provider choice* is - * stored here — the API key itself never touches this store or localStorage; it - * lives in the OS keychain via the `secret_*` Tauri commands (see - * `lib/api.ts` / `src-tauri/src/secret.rs`). + * dialog opens to, the BYOK provider choice, model selection, telemetry toggles, + * and notification preferences. Only the *provider choice* is stored here — + * the API key itself never touches this store or localStorage; it lives in the + * OS keychain via the `secret_*` Tauri commands (see `lib/api.ts` / + * `src-tauri/src/secret.rs`). */ import { create } from "zustand"; @@ -16,6 +17,10 @@ const LS = { theme: "theme", defaultImportFolder: "defaultImportFolder", byokProvider: "byokProvider", + byokModel: "byokModel", + telemetryUsage: "telemetryUsage", + telemetryCrash: "telemetryCrash", + notificationsGeneration: "notificationsGeneration", } as const; function loadTheme(): Theme { @@ -26,6 +31,12 @@ function loadString(key: string): string | null { if (typeof localStorage === "undefined") return null; return localStorage.getItem(key); } +function loadBool(key: string, fallback: boolean): boolean { + if (typeof localStorage === "undefined") return fallback; + const v = localStorage.getItem(key); + if (v === null) return fallback; + return v === "true"; +} function loadProvider(): ByokProvider { const v = loadString(LS.byokProvider); return v === "openai" || v === "google" ? v : "anthropic"; @@ -35,20 +46,35 @@ function persist(key: string, value: string | null) { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value); } +function persistBool(key: string, value: boolean) { + persist(key, value ? "true" : "false"); +} interface SettingsState { theme: Theme; defaultImportFolder: string | null; byokProvider: ByokProvider; + byokModel: string; + telemetryUsage: boolean; + telemetryCrash: boolean; + notificationsGeneration: boolean; setTheme: (theme: Theme) => void; setDefaultImportFolder: (path: string | null) => void; setByokProvider: (provider: ByokProvider) => void; + setByokModel: (model: string) => void; + setTelemetryUsage: (v: boolean) => void; + setTelemetryCrash: (v: boolean) => void; + setNotificationsGeneration: (v: boolean) => void; } export const useSettingsStore = create((set) => ({ theme: loadTheme(), defaultImportFolder: loadString(LS.defaultImportFolder), byokProvider: loadProvider(), + byokModel: loadString(LS.byokModel) ?? "claude-sonnet-4-20250514", + telemetryUsage: loadBool(LS.telemetryUsage, false), + telemetryCrash: loadBool(LS.telemetryCrash, false), + notificationsGeneration: loadBool(LS.notificationsGeneration, true), setTheme: (theme) => { persist(LS.theme, theme); applyTheme(theme); @@ -62,6 +88,22 @@ export const useSettingsStore = create((set) => ({ persist(LS.byokProvider, byokProvider); set({ byokProvider }); }, + setByokModel: (byokModel) => { + persist(LS.byokModel, byokModel); + set({ byokModel }); + }, + setTelemetryUsage: (telemetryUsage) => { + persistBool(LS.telemetryUsage, telemetryUsage); + set({ telemetryUsage }); + }, + setTelemetryCrash: (telemetryCrash) => { + persistBool(LS.telemetryCrash, telemetryCrash); + set({ telemetryCrash }); + }, + setNotificationsGeneration: (notificationsGeneration) => { + persistBool(LS.notificationsGeneration, notificationsGeneration); + set({ notificationsGeneration }); + }, })); /** Reflect the theme onto the document root so tokens can switch on it. */ @@ -74,4 +116,4 @@ export function applyTheme(theme: Theme): void { /** Apply the persisted theme at startup. */ export function initTheme(): void { applyTheme(useSettingsStore.getState().theme); -} +} \ No newline at end of file