diff --git a/e2e/worktree.spec.ts b/e2e/worktree.spec.ts index b87b7c9..18a7134 100644 --- a/e2e/worktree.spec.ts +++ b/e2e/worktree.spec.ts @@ -1,5 +1,5 @@ import { execSync } from "node:child_process"; -import { existsSync, mkdtempSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { _electron as electron, type ElectronApplication } from "@playwright/test"; @@ -63,6 +63,68 @@ test("creating a session with a branch name creates a worktree on disk", async ( expect(worktreeList).toContain("test-branch"); }); +test("worktreeBaseDir setting directs worktrees to a custom location", async () => { + const window = await app.firstWindow(); + + // Create a custom base directory for worktrees + const customBaseDir = realpathSync(mkdtempSync(path.join(tmpdir(), "codez-e2e-custom-wt-"))); + + // Save the worktreeBaseDir setting before creating the session + await window.evaluate( + async ([baseDir]) => { + await (window as any).electronAPI.saveSettings({ worktreeBaseDir: baseDir }); + }, + [customBaseDir], + ); + + // Create a .claude dir in the main repo so we can verify the symlink + const claudeDir = path.join(repoDir, ".claude"); + mkdirSync(claudeDir); + writeFileSync(path.join(claudeDir, "settings.local.json"), '{"permissions":{}}'); + + // Create a session with a branch — worktree should go under customBaseDir + const session = await window.evaluate( + async ([repoPath]) => { + await (window as any).electronAPI.addRepo(repoPath); + return await (window as any).electronAPI.createSession( + repoPath, + "claude", + "custom-loc", + ); + }, + [repoDir], + ); + + const repoName = path.basename(repoDir); + const expectedPath = path.join(customBaseDir, `${repoName}--custom-loc`); + + // Session should record the custom worktree path + expect((session as any).worktreePath).toBe(expectedPath); + + // Worktree directory should exist on disk + expect(existsSync(expectedPath)).toBe(true); + + // The default sibling location should NOT have been created + const siblingPath = `${repoDir}--custom-loc`; + expect(existsSync(siblingPath)).toBe(false); + + // Verify git recognises the worktree + const worktreeList = execSync("git worktree list", { + cwd: repoDir, + encoding: "utf-8", + }); + expect(worktreeList).toContain("custom-loc"); + expect(worktreeList).toContain(expectedPath); + + // .claude should be symlinked from the main repo into the custom worktree + const worktreeClaudeDir = path.join(expectedPath, ".claude"); + expect(existsSync(worktreeClaudeDir)).toBe(true); + expect(lstatSync(worktreeClaudeDir).isSymbolicLink()).toBe(true); + expect(realpathSync(worktreeClaudeDir)).toBe(claudeDir); + // Permissions file is accessible through the symlink + expect(existsSync(path.join(worktreeClaudeDir, "settings.local.json"))).toBe(true); +}); + test("creating a session without a branch uses the repo directly", async () => { const window = await app.firstWindow(); diff --git a/resources/icon-ytp-30s-audio.mp4 b/resources/icon-ytp-30s-audio.mp4 new file mode 100644 index 0000000..2e46abe Binary files /dev/null and b/resources/icon-ytp-30s-audio.mp4 differ diff --git a/resources/icon-ytp-30s.mp4 b/resources/icon-ytp-30s.mp4 new file mode 100644 index 0000000..56d1b84 Binary files /dev/null and b/resources/icon-ytp-30s.mp4 differ diff --git a/resources/icon-ytp-loop.mp4 b/resources/icon-ytp-loop.mp4 new file mode 100644 index 0000000..d42b673 Binary files /dev/null and b/resources/icon-ytp-loop.mp4 differ diff --git a/resources/icon-ytp.gif b/resources/icon-ytp.gif new file mode 100644 index 0000000..b0d23af Binary files /dev/null and b/resources/icon-ytp.gif differ diff --git a/resources/icon-ytp.mp4 b/resources/icon-ytp.mp4 new file mode 100644 index 0000000..54d4ccd Binary files /dev/null and b/resources/icon-ytp.mp4 differ diff --git a/resources/icon-ytp.png b/resources/icon-ytp.png new file mode 100644 index 0000000..de50cc0 Binary files /dev/null and b/resources/icon-ytp.png differ diff --git a/resources/make-ytp-audio.scd b/resources/make-ytp-audio.scd new file mode 100644 index 0000000..d6e86b5 --- /dev/null +++ b/resources/make-ytp-audio.scd @@ -0,0 +1,245 @@ +// ============================================================ +// YTP Glitch Audio for Codez — SuperCollider NRT Score +// Renders a 30-second glitchy soundtrack to WAV +// ============================================================ + +( +// Use environment vars (~) for pipe compatibility +~server = Server(\nrt, + options: ServerOptions.new + .numOutputBusChannels_(2) + .numInputBusChannels_(0) + .sampleRate_(48000) +); + +~score = Score.new; +~nodeId = 1000; +~nextId = { ~nodeId = ~nodeId + 1; ~nodeId }; + +// --- SynthDefs --- + +~bassStab = SynthDef(\bassStab, { |out, freq = 80, amp = 0.5, dur = 0.15| + var env = EnvGen.kr(Env.perc(0.005, dur, amp), doneAction: 2); + var sig = (SinOsc.ar(freq) + Pulse.ar(freq * 0.99, 0.3, 0.3)).distort; + sig = sig * env; + Out.ar(out, sig.dup); +}).asBytes; + +~noiseBurst = SynthDef(\noiseBurst, { |out, amp = 0.4, dur = 0.1, bits = 4, rate = 8000| + var env = EnvGen.kr(Env.perc(0.001, dur, amp), doneAction: 2); + var sig = WhiteNoise.ar; + // Bitcrushing without sc3-plugins: sample-rate reduction + bit depth reduction + var steps = (2 ** bits); + sig = Latch.ar(sig, Impulse.ar(rate)); + sig = (sig * steps).round / steps; + sig = sig * env; + Out.ar(out, sig.dup); +}).asBytes; + +~glitchTone = SynthDef(\glitchTone, { |out, freq = 440, amp = 0.3, dur = 0.2| + var env = EnvGen.kr(Env.perc(0.002, dur, amp), doneAction: 2); + var mod = LFNoise0.kr(30).range(0.5, 2); + var sig = Saw.ar(freq * mod) + Pulse.ar(freq * mod * 1.01, LFNoise2.kr(15).range(0.1, 0.9), 0.4); + sig = (sig * 2).clip2(0.8) * env; + Out.ar(out, sig.dup); +}).asBytes; + +~stutter = SynthDef(\stutter, { |out, freq = 200, amp = 0.35, dur = 0.3| + var env = EnvGen.kr(Env.perc(0.001, dur, amp), doneAction: 2); + var sig = SinOsc.ar(freq); + sig = Latch.ar(sig, Impulse.ar(LFNoise0.kr(20).range(100, 4000))); + sig = sig * env; + Out.ar(out, sig.dup); +}).asBytes; + +~sweep = SynthDef(\sweep, { |out, startFreq = 100, endFreq = 2000, amp = 0.3, dur = 0.5| + var env = EnvGen.kr(Env.perc(0.01, dur, amp), doneAction: 2); + var freq = XLine.kr(startFreq, endFreq, dur); + var sig = Saw.ar(freq) * env; + Out.ar(out, sig.dup); +}).asBytes; + +~kick = SynthDef(\kick, { |out, amp = 0.6| + var env = EnvGen.kr(Env.perc(0.005, 0.3, amp), doneAction: 2); + var fenv = EnvGen.kr(Env.perc(0.001, 0.08), levelScale: 300, levelBias: 40); + var sig = SinOsc.ar(fenv) * env; + Out.ar(out, sig.dup); +}).asBytes; + +~laser = SynthDef(\laser, { |out, amp = 0.3, dur = 0.15| + var env = EnvGen.kr(Env.perc(0.001, dur, amp), doneAction: 2); + var freq = XLine.kr(3000, 100, dur); + var sig = Pulse.ar(freq, 0.5) * env; + Out.ar(out, sig.dup); +}).asBytes; + +~staticNoise = SynthDef(\staticNoise, { |out, amp = 0.2, dur = 0.5| + var env = EnvGen.kr(Env.linen(0.01, dur - 0.02, 0.01, amp), doneAction: 2); + var sig = Dust2.ar(8000) * 0.5 + (Crackle.ar(1.95) * 0.3); + sig = sig * env; + Out.ar(out, sig.dup); +}).asBytes; + +// Register SynthDefs at time 0 +~score.add([0.0, ['/d_recv', ~bassStab]]); +~score.add([0.0, ['/d_recv', ~noiseBurst]]); +~score.add([0.0, ['/d_recv', ~glitchTone]]); +~score.add([0.0, ['/d_recv', ~stutter]]); +~score.add([0.0, ['/d_recv', ~sweep]]); +~score.add([0.0, ['/d_recv', ~kick]]); +~score.add([0.0, ['/d_recv', ~laser]]); +~score.add([0.0, ['/d_recv', ~staticNoise]]); + +// --- Timeline (timed to make-ytp.sh video cuts) --- + +// == Intro title (0.0 - 1.88s) == +~score.add([0.0, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.7]]); +~score.add([0.0, [\s_new, \sweep, ~nextId.(), 0, 1, \startFreq, 50, \endFreq, 800, \dur, 0.8, \amp, 0.25]]); +~score.add([0.8, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.06, \amp, 0.5, \bits, 3]]); +~score.add([0.86, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 300, \dur, 0.3, \amp, 0.25]]); +~score.add([1.16, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.15, \amp, 0.3]]); +~score.add([1.31, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.08, \amp, 0.4, \bits, 2]]); +~score.add([1.39, [\s_new, \bassStab, ~nextId.(), 0, 1, \freq, 60, \dur, 0.5, \amp, 0.4]]); + +// == Logo montage (1.88 - 2.88s) == +~score.add([1.88, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 150, \dur, 0.12, \amp, 0.3]]); +~score.add([2.00, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.08, \amp, 0.25]]); +~score.add([2.08, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.1, \amp, 0.35, \bits, 4]]); +~score.add([2.18, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.5]]); +~score.add([2.22, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 500, \dur, 0.06, \amp, 0.3]]); +~score.add([2.28, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 300, \dur, 0.12, \amp, 0.25]]); +~score.add([2.40, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.06, \amp, 0.4, \bits, 2]]); +~score.add([2.58, [\s_new, \bassStab, ~nextId.(), 0, 1, \freq, 80, \dur, 0.3, \amp, 0.35]]); + +// == Agents section (2.88 - 4.38s) == +~score.add([2.88, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.6]]); +~score.add([2.88, [\s_new, \sweep, ~nextId.(), 0, 1, \startFreq, 200, \endFreq, 1500, \dur, 0.7, \amp, 0.2]]); +~score.add([3.58, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.12, \amp, 0.35, \bits, 5]]); +~score.add([3.70, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 400, \dur, 0.06, \amp, 0.3]]); +~score.add([3.76, [\s_new, \bassStab, ~nextId.(), 0, 1, \freq, 100, \dur, 0.5, \amp, 0.3]]); +~score.add([4.26, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.08, \amp, 0.25]]); +~score.add([4.34, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 250, \dur, 0.04, \amp, 0.3]]); + +// == Terminal section (4.38 - 7.08s) == +~score.add([4.38, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.5]]); +~score.add([4.38, [\s_new, \staticNoise, ~nextId.(), 0, 1, \dur, 1.2, \amp, 0.1]]); +~score.add([4.6, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.03, \amp, 0.15, \bits, 8, \rate, 4000]]); +~score.add([4.75, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.03, \amp, 0.15, \bits, 8, \rate, 4000]]); +~score.add([4.9, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.03, \amp, 0.15, \bits, 8, \rate, 4000]]); +~score.add([5.05, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.03, \amp, 0.15, \bits, 8, \rate, 4000]]); +~score.add([5.2, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.03, \amp, 0.15, \bits, 8, \rate, 4000]]); +~score.add([5.58, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 600, \dur, 0.15, \amp, 0.2]]); +~score.add([5.73, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.05, \amp, 0.5, \bits, 2]]); +~score.add([5.78, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 400, \dur, 0.1, \amp, 0.25]]); +~score.add([5.88, [\s_new, \bassStab, ~nextId.(), 0, 1, \freq, 70, \dur, 0.4, \amp, 0.3]]); +~score.add([6.68, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.1, \amp, 0.2]]); + +// == Worktrees section (7.08 - 8.36s) == +~score.add([7.08, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.6]]); +~score.add([7.08, [\s_new, \sweep, ~nextId.(), 0, 1, \startFreq, 100, \endFreq, 600, \dur, 0.6, \amp, 0.2]]); +~score.add([7.68, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.1, \amp, 0.3, \bits, 3]]); +~score.add([7.76, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 200, \dur, 0.08, \amp, 0.25]]); +~score.add([7.84, [\s_new, \bassStab, ~nextId.(), 0, 1, \freq, 90, \dur, 0.5, \amp, 0.35]]); +~score.add([8.28, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.06, \amp, 0.3, \bits, 4]]); +~score.add([8.32, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.04, \amp, 0.2]]); + +// == Session section (8.36 - 10.15s) == +~score.add([8.36, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.55]]); +~score.add([8.36, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 350, \dur, 0.9, \amp, 0.15]]); +~score.add([9.26, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.12, \amp, 0.35, \bits, 3]]); +~score.add([9.38, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 500, \dur, 0.08, \amp, 0.25]]); +~score.add([9.86, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.03, \amp, 0.5, \bits, 2]]); +~score.add([9.89, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.06, \amp, 0.3]]); + +// == Multi-agent section (10.15 - 11.47s) == +~score.add([10.15, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.5]]); +~score.add([10.15, [\s_new, \sweep, ~nextId.(), 0, 1, \startFreq, 2000, \endFreq, 100, \dur, 0.7, \amp, 0.2]]); +~score.add([10.85, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.1, \amp, 0.3, \bits, 4]]); +~score.add([10.95, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 180, \dur, 0.06, \amp, 0.25]]); +~score.add([11.01, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 700, \dur, 0.4, \amp, 0.2]]); +~score.add([11.39, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.08, \amp, 0.2]]); + +// == Keyboard section (11.47 - 12.7s) == +~score.add([11.47, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.6]]); +~score.add([11.47, [\s_new, \bassStab, ~nextId.(), 0, 1, \freq, 65, \dur, 0.6, \amp, 0.35]]); +~score.add([12.07, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.08, \amp, 0.3, \bits, 3]]); +~score.add([12.12, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 450, \dur, 0.05, \amp, 0.25]]); +~score.add([12.20, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 350, \dur, 0.5, \amp, 0.2]]); + +// == Desktop section (12.7 - 14.18s) == +~score.add([12.7, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.55]]); +~score.add([12.7, [\s_new, \sweep, ~nextId.(), 0, 1, \startFreq, 150, \endFreq, 1000, \dur, 0.8, \amp, 0.2]]); +~score.add([13.5, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.1, \amp, 0.3, \bits, 4]]); +~score.add([13.6, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.06, \amp, 0.25]]); +~score.add([14.1, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.04, \amp, 0.5, \bits, 2]]); + +// == Stack section (14.18 - 15.76s) == +~score.add([14.18, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.6]]); +~score.add([14.18, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 250, \dur, 0.8, \amp, 0.15]]); +~score.add([14.98, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 600, \dur, 0.12, \amp, 0.25]]); +~score.add([15.10, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.06, \amp, 0.3, \bits, 3]]); +~score.add([15.16, [\s_new, \bassStab, ~nextId.(), 0, 1, \freq, 80, \dur, 0.5, \amp, 0.3]]); +~score.add([15.68, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.08, \amp, 0.2]]); + +// == macOS section (15.76 - 16.84s) == +~score.add([15.76, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.5]]); +~score.add([15.76, [\s_new, \sweep, ~nextId.(), 0, 1, \startFreq, 500, \endFreq, 200, \dur, 0.6, \amp, 0.2]]); +~score.add([16.36, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.08, \amp, 0.3, \bits, 5]]); +~score.add([16.44, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 380, \dur, 0.4, \amp, 0.2]]); + +// == Rapid-fire logo montage (16.84 - 17.24s) == +~score.add([16.84, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.7]]); +~score.add([16.84, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.06, \amp, 0.4, \bits, 2]]); +~score.add([16.90, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.05, \amp, 0.35]]); +~score.add([16.95, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 800, \dur, 0.04, \amp, 0.3]]); +~score.add([16.99, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.04, \amp, 0.45, \bits, 3]]); +~score.add([17.03, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 1000, \dur, 0.03, \amp, 0.3]]); +~score.add([17.06, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.03, \amp, 0.3]]); +~score.add([17.09, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.03, \amp, 0.4, \bits, 2]]); +~score.add([17.12, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.5]]); +~score.add([17.15, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 1200, \dur, 0.03, \amp, 0.3]]); +~score.add([17.18, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.06, \amp, 0.5, \bits, 2]]); +~score.add([17.21, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.03, \amp, 0.3]]); + +// == Glitch montage (17.24 - 17.55s) == +~score.add([17.24, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.08, \amp, 0.35, \bits, 3]]); +~score.add([17.30, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 500, \dur, 0.06, \amp, 0.3]]); +~score.add([17.36, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 600, \dur, 0.05, \amp, 0.25]]); +~score.add([17.41, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.04, \amp, 0.3]]); +~score.add([17.45, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.04, \amp, 0.4, \bits, 2]]); +~score.add([17.49, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.5]]); +~score.add([17.52, [\s_new, \stutter, ~nextId.(), 0, 1, \freq, 900, \dur, 0.03, \amp, 0.25]]); + +// == Outro title (17.55 - 20.24s) == +~score.add([17.55, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.7]]); +~score.add([17.55, [\s_new, \bassStab, ~nextId.(), 0, 1, \freq, 50, \dur, 1.0, \amp, 0.4]]); +~score.add([17.55, [\s_new, \sweep, ~nextId.(), 0, 1, \startFreq, 2000, \endFreq, 50, \dur, 1.0, \amp, 0.15]]); +~score.add([18.55, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.15, \amp, 0.35, \bits, 3]]); +~score.add([18.70, [\s_new, \laser, ~nextId.(), 0, 1, \dur, 0.04, \amp, 0.3]]); +~score.add([18.74, [\s_new, \glitchTone, ~nextId.(), 0, 1, \freq, 200, \dur, 1.5, \amp, 0.1]]); +~score.add([18.74, [\s_new, \staticNoise, ~nextId.(), 0, 1, \dur, 1.5, \amp, 0.05]]); + +// == Outro logo (20.24 - 22.15s) == +~score.add([20.24, [\s_new, \kick, ~nextId.(), 0, 1, \amp, 0.6]]); +~score.add([20.24, [\s_new, \bassStab, ~nextId.(), 0, 1, \freq, 60, \dur, 0.8, \amp, 0.3]]); +~score.add([21.04, [\s_new, \noiseBurst, ~nextId.(), 0, 1, \dur, 0.1, \amp, 0.2, \bits, 5]]); +~score.add([21.14, [\s_new, \sweep, ~nextId.(), 0, 1, \startFreq, 300, \endFreq, 50, \dur, 1.0, \amp, 0.15]]); + +// Subtle tail +~score.add([22.0, [\s_new, \staticNoise, ~nextId.(), 0, 1, \dur, 8.0, \amp, 0.03]]); + +// End marker +~score.add([30.0, [0]]); + +// --- Render --- +~score.sort; + +~oscPath = "/tmp/codez-ytp-score.osc"; ~outPath = "/tmp/codez-ytp-audio.wav"; + +"Writing OSC score...".postln; ~score.writeOSCFile(~oscPath); "Score written.".postln; + +// Call scsynth NRT directly — build cmd on one line so pipe mode evaluates it together +~cmd = "/Applications/SuperCollider.app/Contents/Resources/scsynth -N /tmp/codez-ytp-score.osc _ /tmp/codez-ytp-audio.wav 48000 wav int24 -o 2"; ("Running: " ++ ~cmd).postln; ~cmd.systemCmd; + +"Done! Rendered /tmp/codez-ytp-audio.wav".postln; ~server.remove; 0.exit; +) diff --git a/resources/make-ytp.sh b/resources/make-ytp.sh new file mode 100755 index 0000000..292ab9d --- /dev/null +++ b/resources/make-ytp.sh @@ -0,0 +1,435 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================ +# YouTube Poop style video generator for Codez +# Edit the SCENES section below, then run: ./make-ytp.sh +# ============================================================ + +FONT="/Users/daniel.klevebring/Library/Fonts/GeistMono-VariableFont_wght.ttf" +S=1024 +TMPDIR="/tmp/codez-ytp" +OUTPUT="icon-ytp-30s.mp4" +DURATION=30 + +mkdir -p "$TMPDIR" + +# ============================================================ +# COPY — All editable text lives here +# ============================================================ + +# Title card +TITLE_MAIN="CODEZ" +TITLE_SUB="AI CODING AGENTS" + +# Agents card +AGENTS_HEADING="POWERED BY" +AGENT_1="Claude Code" +AGENT_2="Gemini CLI" +AGENT_3="Mistral Vibe" + +# Feature: worktrees +WORKTREES_LINE1="GIT" +WORKTREES_LINE2="WORKTREES" +WORKTREES_SUB="parallel sessions across branches" + +# Feature: multi-agent +MULTI_MAIN="MULTI-AGENT" +MULTI_SUB="run agents in parallel" + +# Feature: keyboard +KEYBOARD_LINE1="KEYBOARD" +KEYBOARD_LINE2="FIRST" + +# Terminal session lines +TERM_CMD='$ claude -p "fix the bug"' +TERM_THINKING='Thinking...' +TERM_FOUND='Found issue in stream-parser.ts:42' +TERM_DETAIL1='Buffer not flushed on chunk' +TERM_DETAIL2='boundaries -> partial JSON lines.' +TERM_FIX1='Fixed: stream-parser.ts' +TERM_FIX2='Tests passing (199/199)' +TERM_COMMIT='$ git commit -m "fix: flush buffer"' +TERM_RESULT1='> Session completed in 4.2s' +TERM_RESULT2='> Files changed: 1' +TERM_RESULT3='> Lines: +3 -1' + +# Session management +SESSION_LINE1="SESSIONS" +SESSION_SUB="create, resume, run side by side" + +# Desktop app +DESKTOP_LINE1="NATIVE" +DESKTOP_LINE2="DESKTOP APP" +DESKTOP_SUB="not another web wrapper" + +# Tech stack (top to bottom) +STACK_1="Electron 40" +STACK_2="React 19" +STACK_3="TypeScript" +STACK_4="Tailwind CSS 4" +STACK_5="SQLite + WAL" +STACK_6="Zustand 5" + +# Error flash +ERROR_TEXT="ERROR" + +# macOS card +MACOS_TEXT="macOS only" + +# ============================================================ +# SCENES — Uses the copy above. Edit layout/colors here. +# ============================================================ + +echo "Creating scenes..." + +# --- Title --- +magick -size ${S}x${S} xc:black \ + -font "$FONT" -pointsize 180 -fill "rgb(0,255,200)" \ + -gravity center -annotate +0-50 "$TITLE_MAIN" \ + -pointsize 40 -fill "rgb(255,50,200)" \ + -annotate +0+80 "$TITLE_SUB" \ + -blur 0x2 \ + \( +clone -blur 0x20 \) -compose Screen -composite \ + "$TMPDIR/scene_title.png" + +# --- Powered by (agent names) --- +magick -size ${S}x${S} xc:"rgb(10,0,30)" \ + -font "$FONT" -pointsize 60 -fill "rgb(255,100,0)" \ + -gravity center -annotate +0-120 "$AGENTS_HEADING" \ + -pointsize 80 -fill "rgb(0,200,255)" -annotate +0+0 "$AGENT_1" \ + -pointsize 50 -fill "rgb(200,200,200)" -annotate +0+80 "$AGENT_2" \ + -pointsize 50 -fill "rgb(200,200,200)" -annotate +0+140 "$AGENT_3" \ + -blur 0x1 \ + \( +clone -blur 0x15 \) -compose Screen -composite \ + "$TMPDIR/scene_agents.png" + +# --- Git Worktrees --- +magick -size ${S}x${S} xc:"rgb(0,5,20)" \ + -font "$FONT" -pointsize 100 -fill "rgb(0,255,100)" \ + -gravity center -annotate +0-80 "$WORKTREES_LINE1" \ + -pointsize 120 -fill "rgb(0,255,100)" -annotate +0+60 "$WORKTREES_LINE2" \ + -pointsize 30 -fill "rgb(100,255,150)" -annotate +0+160 "$WORKTREES_SUB" \ + \( +clone -blur 0x25 \) -compose Screen -composite \ + "$TMPDIR/scene_worktrees.png" + +# --- Multi-agent --- +magick -size ${S}x${S} xc:"rgb(20,0,10)" \ + -font "$FONT" -pointsize 90 -fill "rgb(255,0,150)" \ + -gravity center -annotate +0-40 "$MULTI_MAIN" \ + -pointsize 35 -fill "rgb(255,100,200)" -annotate +0+50 "$MULTI_SUB" \ + \( +clone -blur 0x20 \) -compose Screen -composite \ + "$TMPDIR/scene_multi.png" + +# --- Keyboard First --- +magick -size ${S}x${S} xc:"rgb(5,5,15)" \ + -font "$FONT" -pointsize 80 -fill "rgb(255,200,0)" \ + -gravity center -annotate +0-40 "$KEYBOARD_LINE1" \ + -pointsize 100 -fill "rgb(255,220,50)" -annotate +0+70 "$KEYBOARD_LINE2" \ + \( +clone -blur 0x18 \) -compose Screen -composite \ + "$TMPDIR/scene_keyboard.png" + +# --- Fake terminal session --- +magick -size ${S}x${S} xc:black \ + -font "$FONT" -pointsize 28 -fill "rgb(0,255,0)" \ + -gravity NorthWest \ + -annotate +30+30 "$TERM_CMD" \ + -fill "rgb(150,150,255)" \ + -annotate +30+80 "$TERM_THINKING" \ + -fill "rgb(200,200,200)" \ + -annotate +30+130 "$TERM_FOUND" \ + -annotate +30+170 "$TERM_DETAIL1" \ + -annotate +30+210 "$TERM_DETAIL2" \ + -fill "rgb(0,255,100)" \ + -annotate +30+280 "$TERM_FIX1" \ + -annotate +30+320 "$TERM_FIX2" \ + -fill "rgb(255,200,0)" \ + -annotate +30+390 "$TERM_COMMIT" \ + -fill "rgb(0,200,255)" \ + -annotate +30+460 "$TERM_RESULT1" \ + -annotate +30+500 "$TERM_RESULT2" \ + -annotate +30+540 "$TERM_RESULT3" \ + "$TMPDIR/scene_terminal.png" + +# --- Session management --- +magick -size ${S}x${S} xc:"rgb(15,15,25)" \ + -font "$FONT" -pointsize 100 -fill "rgb(100,150,255)" \ + -gravity center -annotate +0-40 "$SESSION_LINE1" \ + -pointsize 30 -fill "rgb(150,180,255)" \ + -annotate +0+50 "$SESSION_SUB" \ + \( +clone -blur 0x20 \) -compose Screen -composite \ + "$TMPDIR/scene_session.png" + +# --- Desktop app --- +magick -size ${S}x${S} xc:"rgb(30,30,50)" \ + -font "$FONT" -pointsize 80 -fill "rgb(255,255,255)" \ + -gravity center -annotate +0-80 "$DESKTOP_LINE1" \ + -pointsize 100 -fill "rgb(255,255,255)" -annotate +0+40 "$DESKTOP_LINE2" \ + -pointsize 30 -fill "rgb(150,150,180)" -annotate +0+120 "$DESKTOP_SUB" \ + \( +clone -blur 0x15 \) -compose Screen -composite \ + "$TMPDIR/scene_desktop.png" + +# --- Tech stack --- +magick -size ${S}x${S} xc:"rgb(0,10,20)" \ + -font "$FONT" -pointsize 50 -fill "rgb(100,200,255)" \ + -gravity center \ + -annotate +0-200 "$STACK_1" \ + -fill "rgb(100,220,255)" -annotate +0-120 "$STACK_2" \ + -fill "rgb(150,130,255)" -annotate +0-40 "$STACK_3" \ + -fill "rgb(0,200,150)" -annotate +0+40 "$STACK_4" \ + -fill "rgb(255,180,50)" -annotate +0+120 "$STACK_5" \ + -fill "rgb(255,100,150)" -annotate +0+200 "$STACK_6" \ + \( +clone -blur 0x12 \) -compose Screen -composite \ + "$TMPDIR/scene_stack.png" + +# --- Error flash --- +magick -size ${S}x${S} xc:"rgb(255,0,0)" \ + -font "$FONT" -pointsize 200 -fill white \ + -gravity center -annotate +0+0 "$ERROR_TEXT" \ + "$TMPDIR/scene_error.png" + +# --- macOS only --- +magick -size ${S}x${S} xc:black \ + -font "$FONT" -pointsize 60 -fill "rgb(200,200,200)" \ + -gravity center -annotate +0+0 "$MACOS_TEXT" \ + "$TMPDIR/scene_macos.png" + +# ============================================================ +# GLITCH VARIANTS — auto-generated from scenes above +# ============================================================ + +echo "Creating glitch variants..." + +for scene in "$TMPDIR"/scene_*.png; do + base=$(basename "$scene" .png) + # A: RGB shift + oversaturate + magick "$scene" -modulate 120,350,100 \ + \( +clone -channel R -separate +channel -roll +12+4 \) \ + \( +clone -channel B -separate +channel -roll -8-3 \) \ + -delete 1 -combine "$TMPDIR/${base}_a.png" + # B: Negate + swirl + magick "$scene" -negate -swirl 60 -modulate 100,250,140 "$TMPDIR/${base}_b.png" + # C: Posterize + wave + magick "$scene" -posterize 4 -wave 12x100 -gravity center \ + -crop ${S}x${S}+0+0 +repage "$TMPDIR/${base}_c.png" +done + +# Glitch the logo icons too +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +for icon in "$SCRIPT_DIR"/icon-0?.png "$SCRIPT_DIR"/icon.png; do + [ -f "$icon" ] || continue + base=$(basename "$icon" .png) + magick "$icon" -modulate 120,400,100 -swirl 30 "$TMPDIR/logo_${base}_g.png" + magick "$icon" -negate -posterize 6 -modulate 100,300,150 "$TMPDIR/logo_${base}_n.png" +done + +# ============================================================ +# TIMELINE — Edit durations and sequence order here +# Format: "file duration" per line. Shorter = more chaotic. +# ============================================================ + +echo "Building timeline..." + +T="$TMPDIR" +cat > "$TMPDIR/timeline.txt" << EOF +file '$T/scene_title.png' +duration 0.8 +file '$T/scene_error.png' +duration 0.06 +file '$T/scene_title_a.png' +duration 0.3 +file '$T/logo_icon_g.png' +duration 0.15 +file '$T/scene_title_b.png' +duration 0.08 +file '$T/scene_title.png' +duration 0.5 + +file '$T/logo_icon-01_g.png' +duration 0.12 +file '$T/logo_icon-02_n.png' +duration 0.08 +file '$T/logo_icon-03_g.png' +duration 0.10 +file '$T/scene_error.png' +duration 0.04 +file '$T/logo_icon-04_n.png' +duration 0.06 +file '$T/logo_icon-05_g.png' +duration 0.12 +file '$T/logo_icon_g.png' +duration 0.3 + +file '$T/scene_agents.png' +duration 0.7 +file '$T/scene_agents_a.png' +duration 0.12 +file '$T/scene_agents_c.png' +duration 0.06 +file '$T/scene_agents.png' +duration 0.5 +file '$T/scene_agents_b.png' +duration 0.08 +file '$T/logo_icon-06_n.png' +duration 0.04 + +file '$T/scene_terminal.png' +duration 1.2 +file '$T/scene_terminal_a.png' +duration 0.15 +file '$T/scene_terminal.png' +duration 0.8 +file '$T/scene_error.png' +duration 0.05 +file '$T/scene_terminal_c.png' +duration 0.1 +file '$T/scene_terminal.png' +duration 0.4 + +file '$T/scene_worktrees.png' +duration 0.6 +file '$T/scene_worktrees_a.png' +duration 0.1 +file '$T/logo_icon-07_g.png' +duration 0.08 +file '$T/scene_worktrees.png' +duration 0.5 +file '$T/scene_worktrees_b.png' +duration 0.06 +file '$T/scene_worktrees_c.png' +duration 0.04 + +file '$T/scene_session.png' +duration 0.9 +file '$T/scene_session_a.png' +duration 0.12 +file '$T/scene_session.png' +duration 0.6 +file '$T/scene_session_b.png' +duration 0.08 +file '$T/scene_error.png' +duration 0.03 +file '$T/scene_session_c.png' +duration 0.06 + +file '$T/scene_multi.png' +duration 0.7 +file '$T/scene_multi_a.png' +duration 0.1 +file '$T/scene_multi_b.png' +duration 0.06 +file '$T/scene_multi.png' +duration 0.4 +file '$T/logo_icon-08_n.png' +duration 0.08 + +file '$T/scene_keyboard.png' +duration 0.6 +file '$T/scene_keyboard_a.png' +duration 0.08 +file '$T/scene_keyboard_c.png' +duration 0.05 +file '$T/scene_keyboard.png' +duration 0.5 + +file '$T/scene_desktop.png' +duration 0.8 +file '$T/scene_desktop_a.png' +duration 0.1 +file '$T/scene_desktop.png' +duration 0.5 +file '$T/scene_desktop_b.png' +duration 0.06 +file '$T/scene_error.png' +duration 0.04 + +file '$T/scene_stack.png' +duration 0.8 +file '$T/scene_stack_a.png' +duration 0.12 +file '$T/scene_stack_b.png' +duration 0.06 +file '$T/scene_stack.png' +duration 0.5 +file '$T/scene_stack_c.png' +duration 0.08 + +file '$T/scene_macos.png' +duration 0.6 +file '$T/scene_macos_a.png' +duration 0.08 +file '$T/scene_macos.png' +duration 0.4 + +file '$T/logo_icon-01_g.png' +duration 0.06 +file '$T/logo_icon-02_n.png' +duration 0.05 +file '$T/logo_icon-03_g.png' +duration 0.04 +file '$T/logo_icon-04_n.png' +duration 0.04 +file '$T/logo_icon-05_g.png' +duration 0.03 +file '$T/logo_icon-06_n.png' +duration 0.03 +file '$T/logo_icon-07_g.png' +duration 0.03 +file '$T/logo_icon-08_n.png' +duration 0.03 +file '$T/logo_icon-09_g.png' +duration 0.03 +file '$T/logo_icon_g.png' +duration 0.06 +file '$T/scene_error.png' +duration 0.03 + +file '$T/scene_terminal_a.png' +duration 0.08 +file '$T/scene_agents_b.png' +duration 0.06 +file '$T/scene_session_c.png' +duration 0.05 +file '$T/scene_worktrees_a.png' +duration 0.04 +file '$T/scene_multi_b.png' +duration 0.04 +file '$T/scene_keyboard_c.png' +duration 0.03 +file '$T/scene_stack_a.png' +duration 0.03 + +file '$T/scene_title.png' +duration 1.0 +file '$T/scene_title_a.png' +duration 0.15 +file '$T/scene_error.png' +duration 0.04 +file '$T/scene_title.png' +duration 1.5 + +file '$T/logo_icon_g.png' +duration 0.8 +file '$T/logo_icon_n.png' +duration 0.1 +file '$T/logo_icon_g.png' +duration 1.0 +file '$T/logo_icon_g.png' +duration 0.01 +EOF + +# ============================================================ +# RENDER — Adjust filters and duration here +# ============================================================ + +echo "Rendering video..." + +ffmpeg -y -f concat -safe 0 -i "$TMPDIR/timeline.txt" \ + -vf "scale=${S}:${S},hue=s=2.5:H=0.3*PI*sin(2*PI*t/3),eq=brightness=0.06*sin(2*PI*t/1.2):saturation=1.8+0.8*sin(2*PI*t/2),rgbashift=rh=-3:bh=3:gv=-2,noise=alls=20:allf=t,unsharp=5:5:1.0" \ + -c:v libx264 -pix_fmt yuv420p -crf 18 \ + -r 30 -t "$DURATION" \ + "$SCRIPT_DIR/$OUTPUT" 2>&1 | tail -3 + +echo "" +echo "Done! Output: $SCRIPT_DIR/$OUTPUT" +ls -lh "$SCRIPT_DIR/$OUTPUT" diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index cf97782..0387867 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -115,7 +115,8 @@ export function registerIpcHandlers(options: RegisterHandlersOptions): void { let resolvedBranch: string | null = null; if (branchName) { - worktreePath = createWorktree(repoPath, branchName); + const settings = readSettings(settingsPath); + worktreePath = createWorktree(repoPath, branchName, settings.worktreeBaseDir); resolvedBranch = branchName; } @@ -262,6 +263,17 @@ export function registerIpcHandlers(options: RegisterHandlersOptions): void { writeSettings(settingsPath, settings); }); + ipcMain.handle(IPC.SETTINGS_SELECT_WORKTREE_DIR, async () => { + const window = getMainWindow(); + if (!window) return null; + const result = await dialog.showOpenDialog(window, { + properties: ["openDirectory", "createDirectory"], + message: "Select a folder for worktrees", + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; + }); + ipcMain.handle(IPC.SETTINGS_GET_SHORTCUTS, () => { return getShortcutOverrides(settingsPath); }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 7d355d6..95b2615 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -29,6 +29,7 @@ const CH = { SETTINGS_SAVE_SHORTCUTS: "settings:saveShortcuts", SETTINGS_GET: "settings:get", SETTINGS_SAVE: "settings:save", + SETTINGS_SELECT_WORKTREE_DIR: "settings:selectWorktreeDir", PTY_CREATE: "pty:create", PTY_INPUT: "pty:input", PTY_RESIZE: "pty:resize", @@ -90,6 +91,7 @@ const api = { ipcRenderer.invoke(CH.SETTINGS_SAVE_SHORTCUTS, overrides), getSettings: () => ipcRenderer.invoke(CH.SETTINGS_GET), saveSettings: (settings: Record) => ipcRenderer.invoke(CH.SETTINGS_SAVE, settings), + selectWorktreeDir: () => ipcRenderer.invoke(CH.SETTINGS_SELECT_WORKTREE_DIR), // Icons getIconDataUrls: () => ipcRenderer.invoke(CH.SETTINGS_GET_ICON_DATA_URLS), setAppIcon: (iconId: string) => ipcRenderer.invoke(CH.SETTINGS_SET_APP_ICON, iconId), diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index 7e31e26..bcb111a 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -84,6 +84,23 @@ describe("saveShortcutOverrides", () => { }); }); +describe("worktreeBaseDir round-trip", () => { + it("persists and reads worktreeBaseDir", () => { + writeSettings(settingsPath, { worktreeBaseDir: "/tmp/my-worktrees" }); + const settings = readSettings(settingsPath); + expect(settings.worktreeBaseDir).toBe("/tmp/my-worktrees"); + }); + + it("merges worktreeBaseDir without clobbering other settings", () => { + writeSettings(settingsPath, { voiceEnabled: true, theme: "midnight" }); + writeSettings(settingsPath, { worktreeBaseDir: "/tmp/trees" }); + const settings = readSettings(settingsPath); + expect(settings.voiceEnabled).toBe(true); + expect(settings.theme).toBe("midnight"); + expect(settings.worktreeBaseDir).toBe("/tmp/trees"); + }); +}); + describe("agentConfigs round-trip", () => { it("persists and reads defaultPermissions for claude", () => { const allowedTools = ["Edit", "Read", "Bash(git *)"]; diff --git a/src/main/worktree/worktree-manager.test.ts b/src/main/worktree/worktree-manager.test.ts index b3d7e0f..46c8290 100644 --- a/src/main/worktree/worktree-manager.test.ts +++ b/src/main/worktree/worktree-manager.test.ts @@ -47,6 +47,16 @@ describe("generateWorktreePath", () => { const result = generateWorktreePath("/Users/dan/project", "feat/login"); expect(result).toBe("/Users/dan/project--feat-login"); }); + + it("uses custom base directory when provided", () => { + const result = generateWorktreePath("/Users/dan/project", "feat-login", "/tmp/worktrees"); + expect(result).toBe("/tmp/worktrees/project--feat-login"); + }); + + it("uses repo basename in custom base directory", () => { + const result = generateWorktreePath("/Users/dan/my-app", "main", "/home/user/trees"); + expect(result).toBe("/home/user/trees/my-app--main"); + }); }); describe("worktree operations", () => { @@ -130,6 +140,17 @@ describe("worktree operations", () => { const worktreePath = createWorktree(repoDir, "no-claude"); expect(fs.existsSync(path.join(worktreePath, ".claude"))).toBe(false); }); + + it("creates worktree under custom base directory when provided", () => { + const customBase = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "codez-wt-custom-"))); + const worktreePath = createWorktree(repoDir, "custom-dir", customBase); + const repoName = path.basename(repoDir); + expect(worktreePath).toBe(path.join(customBase, `${repoName}--custom-dir`)); + expect(fs.existsSync(worktreePath)).toBe(true); + expect(fs.existsSync(path.join(worktreePath, "README.md"))).toBe(true); + // Cleanup + fs.rmSync(customBase, { recursive: true, force: true }); + }); }); describe("removeWorktree", () => { diff --git a/src/main/worktree/worktree-manager.ts b/src/main/worktree/worktree-manager.ts index 0f8eb16..9d149ab 100644 --- a/src/main/worktree/worktree-manager.ts +++ b/src/main/worktree/worktree-manager.ts @@ -10,14 +10,18 @@ export function sanitizeBranchName(name: string): string { .replace(/^-+|-+$/g, ""); } -export function generateWorktreePath(repoPath: string, branchName: string): string { +export function generateWorktreePath(repoPath: string, branchName: string, baseDir?: string): string { const safeName = sanitizeBranchName(branchName); + if (baseDir) { + const repoName = path.basename(repoPath); + return path.join(baseDir, `${repoName}--${safeName}`); + } return `${repoPath}--${safeName}`; } -export function createWorktree(repoPath: string, branchName: string): string { +export function createWorktree(repoPath: string, branchName: string, baseDir?: string): string { const safeName = sanitizeBranchName(branchName); - const worktreePath = generateWorktreePath(repoPath, safeName); + const worktreePath = generateWorktreePath(repoPath, safeName, baseDir); try { execFileSync("git", ["worktree", "add", worktreePath, "-b", safeName], { diff --git a/src/renderer/components/SettingsPanel/SettingsPanel.tsx b/src/renderer/components/SettingsPanel/SettingsPanel.tsx index 5f61539..e8aecd2 100644 --- a/src/renderer/components/SettingsPanel/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel/SettingsPanel.tsx @@ -24,6 +24,7 @@ export function SettingsPanel() { const [activeIcon, setActiveIcon] = useState("icon-01"); const [iconDataUrls, setIconDataUrls] = useState>({}); const [appVersion, setAppVersion] = useState(""); + const [worktreeBaseDir, setWorktreeBaseDir] = useState(); const [updateState, setUpdateState] = useState("idle"); const [updateInfo, setUpdateInfo] = useState({}); @@ -33,6 +34,7 @@ export function SettingsPanel() { if (!settingsOpen || !window.electronAPI) return; window.electronAPI.getSettings().then((settings) => { setActiveIcon(settings.appIcon ?? "icon-01"); + setWorktreeBaseDir(settings.worktreeBaseDir); }); window.electronAPI.getIconDataUrls().then(setIconDataUrls); window.electronAPI.getAppInfo().then((info) => setAppVersion(info.version)); @@ -104,6 +106,21 @@ export function SettingsPanel() { window.electronAPI.quitAndInstall(); }, []); + const handleSelectWorktreeDir = useCallback(async () => { + if (!window.electronAPI) return; + const dir = await window.electronAPI.selectWorktreeDir(); + if (dir) { + setWorktreeBaseDir(dir); + await window.electronAPI.saveSettings({ worktreeBaseDir: dir }); + } + }, []); + + const handleClearWorktreeDir = useCallback(async () => { + if (!window.electronAPI) return; + setWorktreeBaseDir(undefined); + await window.electronAPI.saveSettings({ worktreeBaseDir: undefined }); + }, []); + const handleEscape = useCallback( (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -186,6 +203,37 @@ export function SettingsPanel() { + {/* Worktree Location section */} +
+

Worktree Location

+
+

+ {worktreeBaseDir + ? "Worktrees are created in the folder below." + : "Worktrees are created next to each repo by default."} +

+ {worktreeBaseDir && ( +
+ {worktreeBaseDir} + +
+ )} + +
+
+ {/* Updates section */}

Updates

diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a40a2ef..4475c64 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -35,6 +35,7 @@ export const IPC = { SETTINGS_SAVE_SHORTCUTS: "settings:saveShortcuts", SETTINGS_GET: "settings:get", SETTINGS_SAVE: "settings:save", + SETTINGS_SELECT_WORKTREE_DIR: "settings:selectWorktreeDir", // Icons SETTINGS_GET_ICON_DATA_URLS: "settings:getIconDataUrls", SETTINGS_SET_APP_ICON: "settings:setAppIcon", diff --git a/src/shared/types.ts b/src/shared/types.ts index 12ee659..9c0da4f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -19,6 +19,8 @@ export interface AppSettings { agentConfigs?: Record>; theme?: ThemeId; appIcon?: string; + /** Base directory for worktrees. Defaults to sibling of repo (--). */ + worktreeBaseDir?: string; } export interface ElectronAPI { @@ -58,6 +60,7 @@ export interface ElectronAPI { saveShortcutOverrides: (overrides: Record) => Promise; getSettings: () => Promise; saveSettings: (settings: Partial) => Promise; + selectWorktreeDir: () => Promise; // Icons getIconDataUrls: () => Promise>; setAppIcon: (iconId: string) => Promise;