diff options
| -rw-r--r-- | renderer/src/components/Header.tsx | 23 | ||||
| -rw-r--r-- | renderer/src/components/SettingsPage.tsx | 17 | ||||
| -rw-r--r-- | renderer/src/components/settings/GitSettings.tsx | 78 | ||||
| -rw-r--r-- | renderer/src/styles/globals.css | 93 | ||||
| -rw-r--r-- | src/main/git.ts | 23 | ||||
| -rw-r--r-- | src/main/index.ts | 14 | ||||
| -rw-r--r-- | src/main/ipc/handlers.ts | 27 | ||||
| -rw-r--r-- | src/main/preload.ts | 12 |
8 files changed, 276 insertions, 11 deletions
diff --git a/renderer/src/components/Header.tsx b/renderer/src/components/Header.tsx index 3a530d3..1d954ec 100644 --- a/renderer/src/components/Header.tsx +++ b/renderer/src/components/Header.tsx | |||
| @@ -1,6 +1,8 @@ | |||
| 1 | import React, { useState } from "react"; | 1 | import React, { useState, useEffect } from "react"; |
| 2 | import type { Project, Session, Phase } from "../types"; | 2 | import type { Project, Session, Phase } from "../types"; |
| 3 | 3 | ||
| 4 | const api = window.api; | ||
| 5 | |||
| 4 | type Theme = "dark" | "light"; | 6 | type Theme = "dark" | "light"; |
| 5 | 7 | ||
| 6 | interface HeaderProps { | 8 | interface HeaderProps { |
| @@ -86,6 +88,16 @@ export function Header({ | |||
| 86 | setIsRenamingSession(false); | 88 | setIsRenamingSession(false); |
| 87 | }; | 89 | }; |
| 88 | 90 | ||
| 91 | // ── Maximize ───────────────────────────────────────────────── | ||
| 92 | const [isMaximized, setIsMaximized] = useState(false); | ||
| 93 | |||
| 94 | useEffect(() => { | ||
| 95 | // Returns the unsubscribe function; React cleanup calls it on unmount. | ||
| 96 | // On macOS, clicking the native green traffic light also fires this, | ||
| 97 | // keeping the glyph accurate when native controls are used. | ||
| 98 | return api.onWindowMaximized(setIsMaximized); | ||
| 99 | }, []); | ||
| 100 | |||
| 89 | // ── Branch copy ────────────────────────────────────────────── | 101 | // ── Branch copy ────────────────────────────────────────────── |
| 90 | const [copied, setCopied] = useState(false); | 102 | const [copied, setCopied] = useState(false); |
| 91 | 103 | ||
| @@ -225,6 +237,15 @@ export function Header({ | |||
| 225 | {theme === "dark" ? "[light]" : "[dark]"} | 237 | {theme === "dark" ? "[light]" : "[dark]"} |
| 226 | </button> | 238 | </button> |
| 227 | 239 | ||
| 240 | {/* ── Maximize toggle ── */} | ||
| 241 | <button | ||
| 242 | className="maximize-btn" | ||
| 243 | onClick={() => api.toggleMaximize()} | ||
| 244 | title={isMaximized ? "Restore window" : "Maximize window"} | ||
| 245 | > | ||
| 246 | {isMaximized ? '⊡' : '□'} | ||
| 247 | </button> | ||
| 248 | |||
| 228 | {/* ── Settings button ── */} | 249 | {/* ── Settings button ── */} |
| 229 | <button className="settings-btn" onClick={onOpenSettings} title="Settings"> | 250 | <button className="settings-btn" onClick={onOpenSettings} title="Settings"> |
| 230 | ⚙ | 251 | ⚙ |
diff --git a/renderer/src/components/SettingsPage.tsx b/renderer/src/components/SettingsPage.tsx index 5267665..9ebde44 100644 --- a/renderer/src/components/SettingsPage.tsx +++ b/renderer/src/components/SettingsPage.tsx | |||
| @@ -1,7 +1,8 @@ | |||
| 1 | import React, { useState } from "react"; | 1 | import React, { useState } from "react"; |
| 2 | import { SystemPromptsSettings } from "./settings/SystemPromptsSettings"; | 2 | import { SystemPromptsSettings } from "./settings/SystemPromptsSettings"; |
| 3 | import { GitSettings } from "./settings/GitSettings"; | ||
| 3 | 4 | ||
| 4 | type SettingsSection = "system-prompts"; | 5 | type SettingsSection = "system-prompts" | "git"; |
| 5 | 6 | ||
| 6 | interface SettingsPageProps { | 7 | interface SettingsPageProps { |
| 7 | onClose: () => void; | 8 | onClose: () => void; |
| @@ -16,14 +17,14 @@ export function SettingsPage({ onClose }: SettingsPageProps) { | |||
| 16 | {/* Header — matches the main app header height/style */} | 17 | {/* Header — matches the main app header height/style */} |
| 17 | <div className="settings-header"> | 18 | <div className="settings-header"> |
| 18 | <div className="settings-header-left"> | 19 | <div className="settings-header-left"> |
| 19 | <span className="settings-title">\u2699 Settings</span> | 20 | <span className="settings-title">{'⚙'} Settings</span> |
| 20 | </div> | 21 | </div> |
| 21 | <button | 22 | <button |
| 22 | className="settings-close" | 23 | className="settings-close" |
| 23 | onClick={onClose} | 24 | onClick={onClose} |
| 24 | title="Close settings" | 25 | title="Close settings" |
| 25 | > | 26 | > |
| 26 | \u00d7 | 27 | {'×'} |
| 27 | </button> | 28 | </button> |
| 28 | </div> | 29 | </div> |
| 29 | 30 | ||
| @@ -38,12 +39,20 @@ export function SettingsPage({ onClose }: SettingsPageProps) { | |||
| 38 | > | 39 | > |
| 39 | System Prompts | 40 | System Prompts |
| 40 | </button> | 41 | </button> |
| 41 | {/* Future sections added here */} | 42 | <button |
| 43 | className={`settings-nav-item${ | ||
| 44 | activeSection === "git" ? " active" : "" | ||
| 45 | }`} | ||
| 46 | onClick={() => setActiveSection("git")} | ||
| 47 | > | ||
| 48 | Git | ||
| 49 | </button> | ||
| 42 | </nav> | 50 | </nav> |
| 43 | 51 | ||
| 44 | {/* Content */} | 52 | {/* Content */} |
| 45 | <div className="settings-content"> | 53 | <div className="settings-content"> |
| 46 | {activeSection === "system-prompts" && <SystemPromptsSettings />} | 54 | {activeSection === "system-prompts" && <SystemPromptsSettings />} |
| 55 | {activeSection === "git" && <GitSettings />} | ||
| 47 | </div> | 56 | </div> |
| 48 | </div> | 57 | </div> |
| 49 | </div> | 58 | </div> |
diff --git a/renderer/src/components/settings/GitSettings.tsx b/renderer/src/components/settings/GitSettings.tsx new file mode 100644 index 0000000..0ec9875 --- /dev/null +++ b/renderer/src/components/settings/GitSettings.tsx | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | import React, { useState, useEffect } from "react"; | ||
| 2 | |||
| 3 | const api = window.api; | ||
| 4 | |||
| 5 | export function GitSettings() { | ||
| 6 | // null = setting not yet loaded; true/false = loaded value | ||
| 7 | const [branchingEnabled, setBranchingEnabled] = useState<boolean | null>(null); | ||
| 8 | const [saveStatus, setSaveStatus] = useState<"idle" | "saved">("idle"); | ||
| 9 | |||
| 10 | useEffect(() => { | ||
| 11 | api.getSettings(["git.branchingEnabled"]).then((settings) => { | ||
| 12 | // Key absent or any value other than "true" → false (opt-in default) | ||
| 13 | setBranchingEnabled(settings["git.branchingEnabled"] === "true"); | ||
| 14 | }); | ||
| 15 | }, []); | ||
| 16 | |||
| 17 | const handleToggle = async (enabled: boolean) => { | ||
| 18 | setBranchingEnabled(enabled); | ||
| 19 | if (enabled) { | ||
| 20 | await api.setSetting("git.branchingEnabled", "true"); | ||
| 21 | } else { | ||
| 22 | await api.deleteSetting("git.branchingEnabled"); | ||
| 23 | } | ||
| 24 | setSaveStatus("saved"); | ||
| 25 | setTimeout(() => setSaveStatus("idle"), 1500); | ||
| 26 | }; | ||
| 27 | |||
| 28 | if (branchingEnabled === null) { | ||
| 29 | return ( | ||
| 30 | <div style={{ color: "var(--text-secondary)", fontSize: 12 }}> | ||
| 31 | Loading... | ||
| 32 | </div> | ||
| 33 | ); | ||
| 34 | } | ||
| 35 | |||
| 36 | return ( | ||
| 37 | <div> | ||
| 38 | <div className="settings-section-title">Git</div> | ||
| 39 | <div className="settings-section-desc"> | ||
| 40 | Configure how Claude Flow interacts with your project's git repository | ||
| 41 | during the implement phase. | ||
| 42 | </div> | ||
| 43 | |||
| 44 | <div className="settings-toggle-row"> | ||
| 45 | <label className="settings-toggle-label" htmlFor="git-branching-toggle"> | ||
| 46 | <span className="settings-toggle-text"> | ||
| 47 | <strong>Create a branch per session</strong> | ||
| 48 | <span className="settings-toggle-hint"> | ||
| 49 | When enabled, Claude Flow checks out a new{" "} | ||
| 50 | <code>claude-flow/<name>-<id></code> branch when | ||
| 51 | entering the implement phase. When disabled, commits land on | ||
| 52 | your current branch. | ||
| 53 | </span> | ||
| 54 | </span> | ||
| 55 | <span | ||
| 56 | className={`settings-toggle${branchingEnabled ? " on" : ""}`} | ||
| 57 | role="switch" | ||
| 58 | aria-checked={branchingEnabled} | ||
| 59 | id="git-branching-toggle" | ||
| 60 | tabIndex={0} | ||
| 61 | onClick={() => handleToggle(!branchingEnabled)} | ||
| 62 | onKeyDown={(e) => { | ||
| 63 | if (e.key === " " || e.key === "Enter") { | ||
| 64 | e.preventDefault(); | ||
| 65 | handleToggle(!branchingEnabled); | ||
| 66 | } | ||
| 67 | }} | ||
| 68 | /> | ||
| 69 | </label> | ||
| 70 | {saveStatus === "saved" && ( | ||
| 71 | <span className="settings-custom-badge" style={{ color: "var(--accent)" }}> | ||
| 72 | saved ✓ | ||
| 73 | </span> | ||
| 74 | )} | ||
| 75 | </div> | ||
| 76 | </div> | ||
| 77 | ); | ||
| 78 | } | ||
diff --git a/renderer/src/styles/globals.css b/renderer/src/styles/globals.css index 61a37c0..8e43f3a 100644 --- a/renderer/src/styles/globals.css +++ b/renderer/src/styles/globals.css | |||
| @@ -905,3 +905,96 @@ html[data-theme="light"] .settings-textarea:focus { | |||
| 905 | background: var(--bg-tertiary); | 905 | background: var(--bg-tertiary); |
| 906 | color: var(--text-primary); | 906 | color: var(--text-primary); |
| 907 | } | 907 | } |
| 908 | |||
| 909 | /* ── Settings Toggle Row ─────────────────────────────────────── */ | ||
| 910 | .settings-toggle-row { | ||
| 911 | display: flex; | ||
| 912 | align-items: center; | ||
| 913 | gap: 12px; | ||
| 914 | margin-top: 16px; | ||
| 915 | } | ||
| 916 | |||
| 917 | .settings-toggle-label { | ||
| 918 | display: flex; | ||
| 919 | align-items: flex-start; | ||
| 920 | gap: 14px; | ||
| 921 | cursor: pointer; | ||
| 922 | flex: 1; | ||
| 923 | } | ||
| 924 | |||
| 925 | .settings-toggle-text { | ||
| 926 | display: flex; | ||
| 927 | flex-direction: column; | ||
| 928 | gap: 4px; | ||
| 929 | font-size: 12px; | ||
| 930 | color: var(--text-primary); | ||
| 931 | line-height: 1.4; | ||
| 932 | } | ||
| 933 | |||
| 934 | .settings-toggle-hint { | ||
| 935 | color: var(--text-secondary); | ||
| 936 | font-size: 11px; | ||
| 937 | } | ||
| 938 | |||
| 939 | .settings-toggle-hint code { | ||
| 940 | font-family: monospace; | ||
| 941 | background: var(--bg-tertiary); | ||
| 942 | padding: 1px 3px; | ||
| 943 | border-radius: 2px; | ||
| 944 | } | ||
| 945 | |||
| 946 | /* The pill toggle switch */ | ||
| 947 | .settings-toggle { | ||
| 948 | flex-shrink: 0; | ||
| 949 | width: 36px; | ||
| 950 | height: 20px; | ||
| 951 | border-radius: 10px; | ||
| 952 | background: var(--border); | ||
| 953 | position: relative; | ||
| 954 | cursor: pointer; | ||
| 955 | transition: background 0.2s; | ||
| 956 | outline-offset: 2px; | ||
| 957 | } | ||
| 958 | |||
| 959 | .settings-toggle:focus-visible { | ||
| 960 | outline: 2px solid var(--accent); | ||
| 961 | } | ||
| 962 | |||
| 963 | .settings-toggle::after { | ||
| 964 | content: ""; | ||
| 965 | position: absolute; | ||
| 966 | top: 3px; | ||
| 967 | left: 3px; | ||
| 968 | width: 14px; | ||
| 969 | height: 14px; | ||
| 970 | border-radius: 50%; | ||
| 971 | background: var(--text-secondary); | ||
| 972 | transition: transform 0.2s, background 0.2s; | ||
| 973 | } | ||
| 974 | |||
| 975 | .settings-toggle.on { | ||
| 976 | background: var(--accent); | ||
| 977 | } | ||
| 978 | |||
| 979 | .settings-toggle.on::after { | ||
| 980 | transform: translateX(16px); | ||
| 981 | background: #fff; | ||
| 982 | } | ||
| 983 | |||
| 984 | /* ── Header Maximize Button ──────────────────────────────────── */ | ||
| 985 | .maximize-btn { | ||
| 986 | padding: 5px 8px; | ||
| 987 | background: transparent; | ||
| 988 | border: 1px solid var(--border); | ||
| 989 | border-radius: 2px; | ||
| 990 | color: var(--text-secondary); | ||
| 991 | cursor: pointer; | ||
| 992 | font-size: 13px; | ||
| 993 | font-family: inherit; | ||
| 994 | transition: background 0.15s, color 0.15s; | ||
| 995 | } | ||
| 996 | |||
| 997 | .maximize-btn:hover { | ||
| 998 | background: var(--bg-tertiary); | ||
| 999 | color: var(--text-primary); | ||
| 1000 | } | ||
diff --git a/src/main/git.ts b/src/main/git.ts index 20362a7..ec81e8f 100644 --- a/src/main/git.ts +++ b/src/main/git.ts | |||
| @@ -58,6 +58,29 @@ export function ensureGitRepo(projectPath: string): void { | |||
| 58 | } | 58 | } |
| 59 | 59 | ||
| 60 | // --------------------------------------------------------------------------- | 60 | // --------------------------------------------------------------------------- |
| 61 | // Current branch query | ||
| 62 | // --------------------------------------------------------------------------- | ||
| 63 | |||
| 64 | /** | ||
| 65 | * Returns the name of the currently checked-out branch, | ||
| 66 | * or null if git is unavailable or HEAD is detached. | ||
| 67 | */ | ||
| 68 | export function getCurrentBranch(projectPath: string): string | null { | ||
| 69 | try { | ||
| 70 | return ( | ||
| 71 | execFileSync("git", ["branch", "--show-current"], { | ||
| 72 | cwd: projectPath, | ||
| 73 | stdio: "pipe", | ||
| 74 | }) | ||
| 75 | .toString() | ||
| 76 | .trim() || null | ||
| 77 | ); | ||
| 78 | } catch { | ||
| 79 | return null; | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | // --------------------------------------------------------------------------- | ||
| 61 | // Branch creation | 84 | // Branch creation |
| 62 | // --------------------------------------------------------------------------- | 85 | // --------------------------------------------------------------------------- |
| 63 | 86 | ||
diff --git a/src/main/index.ts b/src/main/index.ts index a7bed00..f913ac0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts | |||
| @@ -1,4 +1,4 @@ | |||
| 1 | import { app, BrowserWindow, Menu } from "electron"; | 1 | import { app, BrowserWindow, Menu, ipcMain } from "electron"; |
| 2 | import path from "node:path"; | 2 | import path from "node:path"; |
| 3 | import { getDb, closeDb } from "./db"; | 3 | import { getDb, closeDb } from "./db"; |
| 4 | import { registerIpcHandlers } from "./ipc/handlers"; | 4 | import { registerIpcHandlers } from "./ipc/handlers"; |
| @@ -23,6 +23,18 @@ function createWindow() { | |||
| 23 | 23 | ||
| 24 | registerIpcHandlers(mainWindow); | 24 | registerIpcHandlers(mainWindow); |
| 25 | 25 | ||
| 26 | // Maximize toggle — works identically on Linux and macOS | ||
| 27 | ipcMain.handle("window:toggleMaximize", () => { | ||
| 28 | if (mainWindow!.isMaximized()) mainWindow!.unmaximize(); | ||
| 29 | else mainWindow!.maximize(); | ||
| 30 | }); | ||
| 31 | |||
| 32 | // Push state to renderer so the button glyph stays accurate. | ||
| 33 | // On macOS, clicking the green traffic light also fires these events, | ||
| 34 | // keeping our custom button in sync with the native control. | ||
| 35 | mainWindow.on("maximize", () => mainWindow!.webContents.send("window:maximized", true)); | ||
| 36 | mainWindow.on("unmaximize", () => mainWindow!.webContents.send("window:maximized", false)); | ||
| 37 | |||
| 26 | if (isDev) { | 38 | if (isDev) { |
| 27 | const url = process.env.VITE_DEV_SERVER_URL ?? "http://localhost:5173"; | 39 | const url = process.env.VITE_DEV_SERVER_URL ?? "http://localhost:5173"; |
| 28 | mainWindow.loadURL(url).finally(() => { | 40 | mainWindow.loadURL(url).finally(() => { |
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index bc7d78d..e0863f3 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts | |||
| @@ -3,7 +3,7 @@ import * as projects from "../db/projects"; | |||
| 3 | import * as sessions from "../db/sessions"; | 3 | import * as sessions from "../db/sessions"; |
| 4 | import * as claude from "../claude"; | 4 | import * as claude from "../claude"; |
| 5 | import * as settingsDb from "../db/settings"; | 5 | import * as settingsDb from "../db/settings"; |
| 6 | import { createSessionBranch, ensureGitIgnore } from "../git"; | 6 | import { createSessionBranch, ensureGitIgnore, ensureGitRepo, getCurrentBranch } from "../git"; |
| 7 | import type { UserPermissionMode } from "../claude/phases"; | 7 | import type { UserPermissionMode } from "../claude/phases"; |
| 8 | import { getDefaultSystemPromptTemplate } from "../claude/phases"; | 8 | import { getDefaultSystemPromptTemplate } from "../claude/phases"; |
| 9 | 9 | ||
| @@ -113,10 +113,27 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { | |||
| 113 | if (nextPhase === "implement") { | 113 | if (nextPhase === "implement") { |
| 114 | const project = projects.getProject(session.project_id); | 114 | const project = projects.getProject(session.project_id); |
| 115 | if (project) { | 115 | if (project) { |
| 116 | const branchName = createSessionBranch(project.path, session.name, session.id); | 116 | const branchingSetting = settingsDb.getSetting("git.branchingEnabled"); |
| 117 | if (branchName) { | 117 | const branchingEnabled = branchingSetting === "true"; // opt-in; default = off |
| 118 | sessions.updateSession(sessionId, { git_branch: branchName }); | 118 | |
| 119 | git_branch = branchName; | 119 | // Always ensure repo + gitignore so commits work regardless of mode |
| 120 | try { ensureGitIgnore(project.path); } catch { /* non-fatal */ } | ||
| 121 | try { ensureGitRepo(project.path); } catch { /* non-fatal */ } | ||
| 122 | |||
| 123 | if (branchingEnabled) { | ||
| 124 | // createSessionBranch internally calls ensureGitIgnore/ensureGitRepo again | ||
| 125 | // (belt-and-suspenders), then checks out a new claude-flow/<slug>-<id> branch | ||
| 126 | const branchName = createSessionBranch(project.path, session.name, session.id); | ||
| 127 | if (branchName) { | ||
| 128 | sessions.updateSession(sessionId, { git_branch: branchName }); | ||
| 129 | git_branch = branchName; | ||
| 130 | } | ||
| 131 | } else { | ||
| 132 | // No new branch — commit to whatever branch is currently checked out. | ||
| 133 | // Store the branch name so autoCommitTurn's boolean guard passes. | ||
| 134 | const currentBranch = getCurrentBranch(project.path) ?? "main"; | ||
| 135 | sessions.updateSession(sessionId, { git_branch: currentBranch }); | ||
| 136 | git_branch = currentBranch; | ||
| 120 | } | 137 | } |
| 121 | } | 138 | } |
| 122 | } | 139 | } |
diff --git a/src/main/preload.ts b/src/main/preload.ts index 52e947b..44467db 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts | |||
| @@ -62,6 +62,10 @@ export interface ClaudeFlowAPI { | |||
| 62 | 62 | ||
| 63 | // Dialogs | 63 | // Dialogs |
| 64 | selectDirectory: () => Promise<string | null>; | 64 | selectDirectory: () => Promise<string | null>; |
| 65 | |||
| 66 | // Window | ||
| 67 | toggleMaximize: () => Promise<void>; | ||
| 68 | onWindowMaximized: (cb: (isMaximized: boolean) => void) => () => void; | ||
| 65 | } | 69 | } |
| 66 | 70 | ||
| 67 | const api: ClaudeFlowAPI = { | 71 | const api: ClaudeFlowAPI = { |
| @@ -127,6 +131,14 @@ const api: ClaudeFlowAPI = { | |||
| 127 | const result = await ipcRenderer.invoke("dialog:selectDirectory"); | 131 | const result = await ipcRenderer.invoke("dialog:selectDirectory"); |
| 128 | return result; | 132 | return result; |
| 129 | }, | 133 | }, |
| 134 | |||
| 135 | // Window | ||
| 136 | toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"), | ||
| 137 | onWindowMaximized: (cb) => { | ||
| 138 | const handler = (_: IpcRendererEvent, val: boolean) => cb(val); | ||
| 139 | ipcRenderer.on("window:maximized", handler); | ||
| 140 | return () => ipcRenderer.removeListener("window:maximized", handler); | ||
| 141 | }, | ||
| 130 | }; | 142 | }; |
| 131 | 143 | ||
| 132 | contextBridge.exposeInMainWorld("api", api); | 144 | contextBridge.exposeInMainWorld("api", api); |
