Skip to content

Commit c81fa97

Browse files
committed
Add color synchronization with collision avoidance for active looms. Fixes #277
- Reduce color palette to 16 visually distinct colors (min distance ≥ 10) - Implement collision detection algorithm to avoid assigning same colors to active looms - Store hex color in metadata for robust color persistence across palette changes - Add `selectDistinctColor` function with fallback to hash-based color selection - Add `colorDistance` utility for euclidean RGB color distance calculation - Add `hexToRgb` converter to support pre-calculated hex colors in launcher - Update `LaunchLoomOptions` to accept pre-calculated `colorHex` parameter - Pass pre-computed color data through to avoid recalculation - Add null-check for worktree list when reading metadata for reused looms
1 parent 4d8de6d commit c81fa97

File tree

6 files changed

+326
-147
lines changed

6 files changed

+326
-147
lines changed

src/lib/LoomLauncher.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { openTerminalWindow, openMultipleTerminalWindows } from '../utils/termin
44
import type { TerminalWindowOptions } from '../utils/terminal.js'
55
import { openIdeWindow } from '../utils/ide.js'
66
import { getDevServerLaunchCommand } from '../utils/dev-server.js'
7-
import { generateColorFromBranchName } from '../utils/color.js'
7+
import { generateColorFromBranchName, hexToRgb } from '../utils/color.js'
88
import { logger } from '../utils/logger.js'
99
import { ClaudeContextManager } from './ClaudeContextManager.js'
1010
import type { SettingsManager } from './SettingsManager.js'
@@ -28,6 +28,7 @@ export interface LaunchLoomOptions {
2828
executablePath?: string // Executable path to use for spin command
2929
sourceEnvOnStart?: boolean // defaults to false if undefined
3030
colorTerminal?: boolean // defaults to true if undefined
31+
colorHex?: string // Pre-calculated hex color from metadata, avoids recalculation
3132
}
3233

3334
/**
@@ -155,7 +156,9 @@ export class LoomLauncher {
155156

156157
// Only generate color if terminal coloring is enabled (default: true)
157158
const backgroundColor = (options.colorTerminal ?? true)
158-
? generateColorFromBranchName(options.branchName).rgb
159+
? options.colorHex
160+
? hexToRgb(options.colorHex)
161+
: generateColorFromBranchName(options.branchName).rgb
159162
: undefined
160163

161164
await openTerminalWindow({
@@ -175,7 +178,9 @@ export class LoomLauncher {
175178
private async launchStandaloneTerminal(options: LaunchLoomOptions): Promise<void> {
176179
// Only generate color if terminal coloring is enabled (default: true)
177180
const backgroundColor = (options.colorTerminal ?? true)
178-
? generateColorFromBranchName(options.branchName).rgb
181+
? options.colorHex
182+
? hexToRgb(options.colorHex)
183+
: generateColorFromBranchName(options.branchName).rgb
179184
: undefined
180185

181186
await openTerminalWindow({
@@ -210,7 +215,9 @@ export class LoomLauncher {
210215

211216
// Only generate color if terminal coloring is enabled (default: true)
212217
const backgroundColor = (options.colorTerminal ?? true)
213-
? generateColorFromBranchName(options.branchName).rgb
218+
? options.colorHex
219+
? hexToRgb(options.colorHex)
220+
: generateColorFromBranchName(options.branchName).rgb
214221
: undefined
215222

216223
return {
@@ -239,7 +246,9 @@ export class LoomLauncher {
239246

240247
// Only generate color if terminal coloring is enabled (default: true)
241248
const backgroundColor = (options.colorTerminal ?? true)
242-
? generateColorFromBranchName(options.branchName).rgb
249+
? options.colorHex
250+
? hexToRgb(options.colorHex)
251+
: generateColorFromBranchName(options.branchName).rgb
243252
: undefined
244253

245254
return {
@@ -264,7 +273,9 @@ export class LoomLauncher {
264273

265274
// Only generate color if terminal coloring is enabled (default: true)
266275
const backgroundColor = (options.colorTerminal ?? true)
267-
? generateColorFromBranchName(options.branchName).rgb
276+
? options.colorHex
277+
? hexToRgb(options.colorHex)
278+
: generateColorFromBranchName(options.branchName).rgb
268279
: undefined
269280

270281
return {

src/lib/LoomManager.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { SettingsManager } from './SettingsManager.js'
1212
import { MetadataManager, type WriteMetadataInput } from './MetadataManager.js'
1313
import { branchExists, executeGitCommand, ensureRepositoryHasCommits, extractIssueNumber, isFileTrackedByGit } from '../utils/git.js'
1414
import { installDependencies } from '../utils/package-manager.js'
15-
import { generateColorFromBranchName } from '../utils/color.js'
15+
import { generateColorFromBranchName, selectDistinctColor, type ColorData } from '../utils/color.js'
1616
import { DatabaseManager } from './DatabaseManager.js'
1717
import { loadEnvIntoProcess, findEnvFileForDatabaseUrl } from '../utils/env.js'
1818
import type { Loom, CreateLoomInput } from '../types/loom.js'
@@ -185,9 +185,26 @@ export class LoomManager {
185185
}
186186
}
187187

188-
// 11. Apply color synchronization (terminal and VSCode) based on settings
188+
// 11. Select color with collision avoidance
189+
// Get hex colors in use from active looms (robust against palette changes)
190+
const activeLooms = await this.listLooms()
191+
const usedHexColors: string[] = []
192+
await Promise.all(
193+
activeLooms.map(async (loom) => {
194+
const metadata = await this.metadataManager.readMetadata(loom.path)
195+
if (metadata?.colorHex) {
196+
usedHexColors.push(metadata.colorHex)
197+
}
198+
})
199+
)
200+
201+
// Select distinct color using hex-based comparison
202+
const colorData = selectDistinctColor(branchName, usedHexColors)
203+
logger.debug(`Selected color ${colorData.hex} for branch ${branchName} (${usedHexColors.length} colors in use)`)
204+
205+
// Apply color synchronization (terminal and VSCode) based on settings
189206
try {
190-
await this.applyColorSynchronization(worktreePath, branchName, settingsData, input.options)
207+
await this.applyColorSynchronization(worktreePath, branchName, colorData, settingsData, input.options)
191208
} catch (error) {
192209
// Log warning but don't fail - colors are cosmetic
193210
logger.warn(
@@ -213,7 +230,7 @@ export class LoomManager {
213230
}
214231
}
215232

216-
// 8. Launch workspace components based on individual flags
233+
// 11.5. Launch workspace components based on individual flags
217234
const enableClaude = input.options?.enableClaude !== false
218235
const enableCode = input.options?.enableCode !== false
219236
const enableDevServer = input.options?.enableDevServer !== false
@@ -248,6 +265,7 @@ export class LoomManager {
248265
...(executablePath && { executablePath }),
249266
sourceEnvOnStart: settingsData.sourceEnvOnStart ?? false,
250267
colorTerminal: input.options?.colorTerminal ?? settingsData.colors?.terminal ?? true,
268+
colorHex: colorData.hex,
251269
})
252270
}
253271

@@ -267,6 +285,7 @@ export class LoomManager {
267285
issue_numbers,
268286
pr_numbers,
269287
issueTracker: this.issueTracker.providerName,
288+
colorHex: colorData.hex,
270289
}
271290
await this.metadataManager.writeMetadata(worktreePath, metadataInput)
272291

@@ -313,6 +332,9 @@ export class LoomManager {
313332
*/
314333
async listLooms(): Promise<Loom[]> {
315334
const worktrees = await this.gitWorktree.listWorktrees()
335+
if (!worktrees) {
336+
return []
337+
}
316338
return await this.mapWorktreesToLooms(worktrees)
317339
}
318340

@@ -741,10 +763,13 @@ export class LoomManager {
741763
* DEFAULTS:
742764
* - terminal: true (always safe, only affects macOS Terminal.app)
743765
* - vscode: false (safe default, prevents unexpected file modifications)
766+
*
767+
* @param colorData - Pre-computed color data (from collision avoidance)
744768
*/
745769
private async applyColorSynchronization(
746770
worktreePath: string,
747771
branchName: string,
772+
colorData: ColorData,
748773
settings: import('./SettingsManager.js').IloomSettings,
749774
options?: CreateLoomInput['options']
750775
): Promise<void> {
@@ -758,8 +783,6 @@ export class LoomManager {
758783
return
759784
}
760785

761-
const colorData = generateColorFromBranchName(branchName)
762-
763786
// Apply VSCode title bar color if enabled (default: disabled for safety)
764787
if (colorVscode) {
765788
const vscode = new VSCodeIntegration()
@@ -869,6 +892,22 @@ export class LoomManager {
869892
logger.info('Database branch assumed to be already configured for existing worktree')
870893
const databaseBranch: string | undefined = undefined
871894

895+
// 5.5. Read existing metadata to get colorHex (for reusing stored color)
896+
const existingMetadata = await this.metadataManager.readMetadata(worktreePath)
897+
898+
// Determine colorHex for launch
899+
let colorHex: string
900+
if (existingMetadata?.colorHex) {
901+
// Use stored hex color (already migrated from colorIndex if needed in readMetadata)
902+
colorHex = existingMetadata.colorHex
903+
logger.debug(`Reusing stored color ${colorHex} for branch ${branchName}`)
904+
} else {
905+
// No metadata - fall back to hash-based
906+
const colorData = generateColorFromBranchName(branchName)
907+
colorHex = colorData.hex
908+
logger.debug(`No stored color, using hash-based color ${colorHex} for branch ${branchName}`)
909+
}
910+
872911
// 6. Move issue to In Progress (for reused worktrees too)
873912
if (input.type === 'issue') {
874913
try {
@@ -920,12 +959,12 @@ export class LoomManager {
920959
...(executablePath && { executablePath }),
921960
sourceEnvOnStart: settingsData.sourceEnvOnStart ?? false,
922961
colorTerminal: input.options?.colorTerminal ?? settingsData.colors?.terminal ?? true,
962+
colorHex,
923963
})
924964
}
925965

926966
// 8. Write loom metadata if missing (spec section 3.1)
927967
// For reused looms, only write if metadata file doesn't exist
928-
const existingMetadata = await this.metadataManager.readMetadata(worktreePath)
929968
const description = existingMetadata?.description ?? issueData?.title ?? branchName
930969
if (!existingMetadata) {
931970
// Build issue/pr numbers arrays based on type
@@ -940,6 +979,7 @@ export class LoomManager {
940979
issue_numbers,
941980
pr_numbers,
942981
issueTracker: this.issueTracker.providerName,
982+
colorHex,
943983
}
944984
await this.metadataManager.writeMetadata(worktreePath, metadataInput)
945985
}

src/lib/MetadataManager.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ describe('MetadataManager', () => {
8282
issue_numbers: ['42'],
8383
pr_numbers: [],
8484
issueTracker: 'github',
85+
colorHex: '#dcebff',
8586
}
8687

8788
beforeEach(() => {
@@ -121,6 +122,7 @@ describe('MetadataManager', () => {
121122
issue_numbers: ['42'],
122123
pr_numbers: [],
123124
issueTracker: 'github',
125+
colorHex: '#dcebff',
124126
})
125127

126128
vi.useRealTimers()
@@ -164,6 +166,7 @@ describe('MetadataManager', () => {
164166
issue_numbers: ['42'],
165167
pr_numbers: [],
166168
issueTracker: 'github',
169+
colorHex: '#f5dceb',
167170
})
168171
vi.mocked(fs.pathExists).mockResolvedValue(true)
169172
vi.mocked(fs.readFile).mockResolvedValue(mockContent)
@@ -179,6 +182,7 @@ describe('MetadataManager', () => {
179182
issue_numbers: ['42'],
180183
pr_numbers: [],
181184
issueTracker: 'github',
185+
colorHex: '#f5dceb',
182186
})
183187
})
184188

@@ -202,6 +206,7 @@ describe('MetadataManager', () => {
202206
issue_numbers: [],
203207
pr_numbers: [],
204208
issueTracker: null,
209+
colorHex: null,
205210
})
206211
})
207212

src/lib/MetadataManager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface MetadataFile {
1818
issue_numbers?: string[]
1919
pr_numbers?: string[]
2020
issueTracker?: string
21+
colorHex?: string // Stored hex color (e.g., "#dcebff") - robust against palette changes
2122
}
2223

2324
/**
@@ -33,6 +34,7 @@ export interface WriteMetadataInput {
3334
issue_numbers: string[]
3435
pr_numbers: string[]
3536
issueTracker: string
37+
colorHex: string // Hex color (e.g., "#dcebff") - robust against palette changes
3638
}
3739

3840
/**
@@ -47,6 +49,7 @@ export interface LoomMetadata {
4749
issue_numbers: string[]
4850
pr_numbers: string[]
4951
issueTracker: string | null
52+
colorHex: string | null // Hex color (e.g., "#dcebff") - robust against palette changes
5053
}
5154

5255
/**
@@ -124,6 +127,7 @@ export class MetadataManager {
124127
issue_numbers: input.issue_numbers,
125128
pr_numbers: input.pr_numbers,
126129
issueTracker: input.issueTracker,
130+
colorHex: input.colorHex,
127131
}
128132

129133
// 3. Write to slugified filename
@@ -171,6 +175,7 @@ export class MetadataManager {
171175
issue_numbers: data.issue_numbers ?? [],
172176
pr_numbers: data.pr_numbers ?? [],
173177
issueTracker: data.issueTracker ?? null,
178+
colorHex: data.colorHex ?? null,
174179
}
175180
} catch (error) {
176181
// Return null on any error (graceful degradation per spec)

0 commit comments

Comments
 (0)