fix: prevent app crash on macOS reactivation by deduplicating IPC handlers (#81)

When macOS re-activates the app after all windows are closed, `createWindow()` is called
again but IPC handlers from the previous window were still registered, causing duplicate
handler errors and crashes. This fix removes stale handlers before re-registering them
in pty, repos, and worktrees modules, and ensures repo/worktree handlers + auto-updater
are also set up on activate. Includes minor lint/style cleanups across the codebase.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinjing 2026-03-23 23:44:12 -07:00 committed by GitHub
parent 598f7c17fa
commit d9facf1b9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 873 additions and 528 deletions

View file

@ -117,6 +117,7 @@ Use the Skill tool: skill: "review-code"
```
After review completes:
- If **Critical or High** issues found: fix them using the Edit tool, then re-run `pn typecheck` to verify the review fixes don't introduce type errors
- If only **Medium or Low** issues: acceptable, continue
- If **no issues**: continue
@ -181,6 +182,7 @@ fi
- `--delete-branch` cleans up the feature branch after merge (skipped in worktrees to avoid checkout conflict)
If merge fails:
1. Check the error message
2. If the error contains `'master' is already used by worktree` or similar: retry without `--delete-branch` and delete the remote branch manually with `git push origin --delete <branch>`
3. If merge conflicts: use Skill tool with `skill: "resolve-conflicts"`, push, then retry merge once

View file

@ -81,6 +81,7 @@ git diff $(git merge-base origin/main HEAD) --stat | tail -1
```
If the diff contains more than 500 changed files, **EXIT immediately** with:
```
❌ Diff too large (>500 files). Please split this PR into smaller, focused changes.
```
@ -101,28 +102,35 @@ Create `00-review-context.md` in the working directory with:
# Review Context
## Branch Info
- Base: origin/main
- Current: [branch name]
## Changed Files Summary
[List all changed files with their change type: A/M/D]
## Changed Line Ranges (PR Scope)
<!-- In scope: issues on these lines OR caused by these changes. Out of scope: unrelated pre-existing issues -->
| File | Changed Lines |
|------|---------------|
| File | Changed Lines |
| ------------------ | ---------------- |
| [path/to/file1.ts] | [45-67, 120-135] |
| [path/to/file2.ts] | [10-25] |
| [path/to/file2.ts] | [10-25] |
## Review Standards Reference
- Follow /review-code standards
- Focus on: correctness, security, performance, maintainability
- Priority levels: Critical > High > Medium > Low
## File Categories
[Categorized list - see below]
## Skipped Issues (Do Not Re-validate)
<!-- Issues validated but deemed not worth fixing. Do not re-validate these in future iterations. -->
<!-- Format: [file:line-range] | [severity] | [reason skipped] | [issue summary] -->
<!-- NOTE: Skips should be RARE - only purely cosmetic issues with no functional impact -->
@ -130,11 +138,12 @@ Create `00-review-context.md` in the working directory with:
[Initially empty - populated during validation phase]
## Iteration State
<!-- Updated after each phase to enable crash recovery -->
Current iteration: 1
Last completed phase: Setup
Files fixed this iteration: []
```
**IMPORTANT**: The "Skipped Issues" section persists across iterations. However, skips should be RARE - only purely cosmetic issues (naming, JSDoc, import ordering) may be skipped. Functional issues like error handling, type safety, and performance must ALWAYS be fixed.
@ -143,17 +152,18 @@ Files fixed this iteration: []
Assign each file to **exactly ONE** file category (no duplicates):
| Priority | Category | File Patterns |
|----------|----------|---------------|
| 1 | Electron/Main | `src/main/`, `src/preload/`, `electron.*` |
| 2 | Backend/IPC | `*ipc*`, `*handler*`, `*service*` (in main process) |
| 3 | Frontend/UI | `src/renderer/`, `components/`, `*.tsx`, `*.css` |
| 4 | Config/Build | `*.config.*`, `package.json`, `tsconfig.*`, `electron-builder.*` |
| 5 | Utility/Common | Everything else |
| Priority | Category | File Patterns |
| -------- | -------------- | ---------------------------------------------------------------- |
| 1 | Electron/Main | `src/main/`, `src/preload/`, `electron.*` |
| 2 | Backend/IPC | `*ipc*`, `*handler*`, `*service*` (in main process) |
| 3 | Frontend/UI | `src/renderer/`, `components/`, `*.tsx`, `*.css` |
| 4 | Config/Build | `*.config.*`, `package.json`, `tsconfig.*`, `electron-builder.*` |
| 5 | Utility/Common | Everything else |
**Deduplication rule**: A file belongs to the FIRST matching category only.
**Tiebreaker rule**: If a file path matches multiple categories at different directory depths:
1. Use the **deepest matching directory** (e.g., `src/main/services/ui/dialog.ts` → Backend/IPC because `services/` is deeper than `main/`)
2. If same depth, use priority order (lower number wins)
@ -161,12 +171,12 @@ Assign each file to **exactly ONE** file category (no duplicates):
Each file category gets reviewed by multiple specialized review commands:
| Category | Review Commands |
|----------|-----------------|
| Electron/Main | `/review-code`, `/review-algorithm-architecture` |
| Backend/IPC | `/review-code`, `/review-algorithm-architecture` |
| Frontend/UI | `/review-code`, `/review-algorithm-architecture` |
| Config/Build | `/review-code` |
| Category | Review Commands |
| -------------- | ------------------------------------------------ |
| Electron/Main | `/review-code`, `/review-algorithm-architecture` |
| Backend/IPC | `/review-code`, `/review-algorithm-architecture` |
| Frontend/UI | `/review-code`, `/review-algorithm-architecture` |
| Config/Build | `/review-code` |
| Utility/Common | `/review-code`, `/review-algorithm-architecture` |
**Total review agents**: (Categories with files) × (Applicable review commands per category)
@ -179,10 +189,10 @@ Spawn review subagents using the Task tool. **CRITICAL**: Use the exact Task() d
### Review Command Descriptions
| Command | Focus | Engine |
|---------|-------|--------|
| `/review-code` | Logical bugs, security issues, TypeScript best practices, error handling | Claude |
| `/review-algorithm-architecture` | File organization, module boundaries, performance on hot paths | Claude |
| Command | Focus | Engine |
| -------------------------------- | ------------------------------------------------------------------------ | ------ |
| `/review-code` | Logical bugs, security issues, TypeScript best practices, error handling | Claude |
| `/review-algorithm-architecture` | File organization, module boundaries, performance on hot paths | Claude |
### Spawn agents by category × review command
@ -243,6 +253,7 @@ Total: 6 parallel review agents
**Launch ALL Task() calls in a single message block for true parallelism.**
**IMPORTANT — WAIT FOR ALL AGENTS**:
- Do NOT use `run_in_background: true` for any Phase 1 review agent.
- Task() calls without `run_in_background` are blocking by default — they return only when the subagent finishes.
- **VERIFICATION CHECKPOINT**: Before starting Phase 2, you MUST count the number of Task() results you received. It MUST equal the total number of Task() calls you made. If any result is missing, DO NOT proceed — wait or re-spawn the missing agent.
@ -290,6 +301,7 @@ Total: 6 parallel review agents
Validate all issues that weren't excluded by the "Skipped Issues" list.
### Grouping strategy:
- Group findings by file
- One validation agent per file (handles all findings for that file)
- Maximum 10 validation agents total
@ -356,6 +368,7 @@ For any issues marked as ⏭️ **Skip**, append them to the "Skipped Issues" se
```markdown
## Skipped Issues (Do Not Re-validate)
<!-- Format: [file:line-range] | [severity] | [reason skipped] | [issue summary] -->
src/utils/helper.ts:42-45 | Low | Stylistic preference | Variable naming convention
@ -398,10 +411,12 @@ git add -A && git commit -m "WIP: Changes before auto-review fixes"
**MANDATORY**: Create an explicit mapping from validation results to fix actions.
For every issue marked "✅ Fix" in validation:
1. Record: `[file] → [issue description] → [suggested fix]`
2. Group by file
**Traceability requirement**: Every "✅ Fix" issue MUST appear in the fix manifest. Do not:
- Skip issues because you believe a different fix "solves the root cause"
- Substitute your judgment for what validation explicitly said to fix
- Rationalize away issues as "will be handled by another fix"
@ -419,6 +434,7 @@ If validation says "✅ Fix" for file X, spawn a fix agent for file X. Period.
### Conflict prevention
To avoid race conditions when multiple agents edit overlapping code:
- Each file should have **at most ONE fix agent running at a time**
- If a file has >5 issues requiring multiple agents, spawn them **sequentially** (not in parallel)
- After each fix agent completes for a multi-agent file, verify the file is syntactically valid before spawning the next
@ -466,6 +482,7 @@ npm run typecheck 2>&1
```
If the type check fails:
- Log which fix agent(s) likely caused the failure
- Continue to Phase 5 - the next iteration's review will catch the type errors as new issues
- Update Iteration State: `Build status: FAILED (type errors in [files])`
@ -473,6 +490,7 @@ If the type check fails:
### Step 6: Verify fix manifest completeness
Before proceeding to Phase 5, verify:
- Every file in the fix manifest had a fix agent spawned
- Every "✅ Fix" issue from validation was addressed by a fix agent
- No issues were skipped due to assumptions about "root cause" fixes elsewhere
@ -506,6 +524,7 @@ OTHERWISE:
**The key insight**: "I fixed all issues" ≠ "There are no issues". Another review round must confirm.
**Note on convergence**: The loop converges because:
1. Fixed issues no longer appear in subsequent reviews
2. Skipped issues are tracked and excluded from future validation
3. Only genuinely new issues trigger additional work
@ -612,13 +631,13 @@ ls -t .context/auto-review-*.md 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/nu
## Token Efficiency Summary
| Before | After | Improvement |
|--------|-------|-------------|
| Full diff to all agents | Relevant files only per category | ~6x reduction |
| Generic review for all | Specialized review commands | Better issue coverage |
| Re-validate skipped issues | Track in Skipped Issues list | ~2x reduction per iteration |
| 1 agent per finding | 1 agent per file (up to 5 issues) | ~5x reduction |
| No deduplication | Files assigned to one category only | ~2x reduction |
| Before | After | Improvement |
| -------------------------- | ----------------------------------- | --------------------------- |
| Full diff to all agents | Relevant files only per category | ~6x reduction |
| Generic review for all | Specialized review commands | Better issue coverage |
| Re-validate skipped issues | Track in Skipped Issues list | ~2x reduction per iteration |
| 1 agent per finding | 1 agent per file (up to 5 issues) | ~5x reduction |
| No deduplication | Files assigned to one category only | ~2x reduction |
---

View file

@ -10,6 +10,7 @@ Comprehensive performance optimization guide for TypeScript applications. Contai
## When to Apply
Reference these guidelines when:
- Configuring tsconfig.json for a new or existing project
- Writing complex type definitions or generics
- Optimizing async/await patterns and data fetching
@ -18,16 +19,16 @@ Reference these guidelines when:
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Type System Performance | CRITICAL | `type-` |
| 2 | Compiler Configuration | CRITICAL | `tscfg-` |
| 3 | Async Patterns | HIGH | `async-` |
| 4 | Module Organization | HIGH | `module-` |
| 5 | Type Safety Patterns | MEDIUM-HIGH | `safety-` |
| 6 | Memory Management | MEDIUM | `mem-` |
| 7 | Runtime Optimization | LOW-MEDIUM | `runtime-` |
| 8 | Advanced Patterns | LOW | `advanced-` |
| Priority | Category | Impact | Prefix |
| -------- | ----------------------- | ----------- | ----------- |
| 1 | Type System Performance | CRITICAL | `type-` |
| 2 | Compiler Configuration | CRITICAL | `tscfg-` |
| 3 | Async Patterns | HIGH | `async-` |
| 4 | Module Organization | HIGH | `module-` |
| 5 | Type Safety Patterns | MEDIUM-HIGH | `safety-` |
| 6 | Memory Management | MEDIUM | `mem-` |
| 7 | Runtime Optimization | LOW-MEDIUM | `runtime-` |
| 8 | Advanced Patterns | LOW | `advanced-` |
## Table of Contents

View file

@ -16,15 +16,19 @@ type UserId = string
type OrderId = string
type ProductId = string
function fetchUser(id: UserId): Promise<User> { /* ... */ }
function fetchOrder(id: OrderId): Promise<Order> { /* ... */ }
function fetchUser(id: UserId): Promise<User> {
/* ... */
}
function fetchOrder(id: OrderId): Promise<Order> {
/* ... */
}
const userId: UserId = 'user-123'
const orderId: OrderId = 'order-456'
// No error - all strings are interchangeable
fetchUser(orderId) // Bug: passed OrderId to UserId parameter
fetchOrder(userId) // Bug: passed UserId to OrderId parameter
fetchUser(orderId) // Bug: passed OrderId to UserId parameter
fetchOrder(userId) // Bug: passed UserId to OrderId parameter
```
**Correct (branded types prevent mixing):**
@ -44,15 +48,19 @@ function createOrderId(id: string): OrderId {
return id as OrderId
}
function fetchUser(id: UserId): Promise<User> { /* ... */ }
function fetchOrder(id: OrderId): Promise<Order> { /* ... */ }
function fetchUser(id: UserId): Promise<User> {
/* ... */
}
function fetchOrder(id: OrderId): Promise<Order> {
/* ... */
}
const userId = createUserId('user-123')
const orderId = createOrderId('order-456')
fetchUser(orderId) // Error: Argument of type 'OrderId' is not assignable to 'UserId'
fetchOrder(userId) // Error: Argument of type 'UserId' is not assignable to 'OrderId'
fetchUser(userId) // OK
fetchUser(orderId) // Error: Argument of type 'OrderId' is not assignable to 'UserId'
fetchOrder(userId) // Error: Argument of type 'UserId' is not assignable to 'OrderId'
fetchUser(userId) // OK
```
**For numeric types:**
@ -70,11 +78,12 @@ function formatPrice(cents: Cents): string {
}
const price = 29.99 as Dollars
formatPrice(price) // Error: Dollars not assignable to Cents
formatPrice(toCents(price)) // OK: '$29.99'
formatPrice(price) // Error: Dollars not assignable to Cents
formatPrice(toCents(price)) // OK: '$29.99'
```
**When to use branded types:**
- Entity IDs that shouldn't be mixed
- Currency/unit conversions
- Validated strings (email, URL, slug)

View file

@ -17,13 +17,13 @@ type ColorConfig = Record<string, [number, number, number]>
const colors: ColorConfig = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 255],
blue: [0, 0, 255]
// Can't access colors.red - it's just string keys
}
// TypeScript doesn't know 'red' is a valid key
const redValue = colors.red // Type: [number, number, number]
const pinkValue = colors.pink // No error! Type: [number, number, number]
const redValue = colors.red // Type: [number, number, number]
const pinkValue = colors.pink // No error! Type: [number, number, number]
```
**Correct (satisfies preserves literal types):**
@ -34,12 +34,12 @@ type ColorConfig = Record<string, [number, number, number]>
const colors = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 255],
blue: [0, 0, 255]
} satisfies ColorConfig
// TypeScript knows exact keys
const redValue = colors.red // Type: [number, number, number]
const pinkValue = colors.pink // Error: Property 'pink' does not exist
const redValue = colors.red // Type: [number, number, number]
const pinkValue = colors.pink // Error: Property 'pink' does not exist
```
**For configuration objects:**
@ -54,18 +54,18 @@ interface Route {
// Without satisfies - loses literal path types
const routes: Route[] = [
{ path: '/', component: Home },
{ path: '/users', component: Users },
{ path: '/users', component: Users }
]
// routes[0].path is just 'string'
// With satisfies - preserves literal paths
const routes = [
{ path: '/', component: Home },
{ path: '/users', component: Users },
{ path: '/users', component: Users }
] satisfies Route[]
// routes[0].path is '/'
type RoutePath = typeof routes[number]['path'] // '/' | '/users'
type RoutePath = (typeof routes)[number]['path'] // '/' | '/users'
```
**Combining with as const:**
@ -74,7 +74,7 @@ type RoutePath = typeof routes[number]['path'] // '/' | '/users'
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
retries: 3
} as const satisfies {
apiUrl: string
timeout: number
@ -82,11 +82,12 @@ const config = {
}
// Both validated AND readonly with literal types
config.apiUrl // Type: 'https://api.example.com' (not just string)
config.timeout = 3000 // Error: Cannot assign to 'timeout' (readonly)
config.apiUrl // Type: 'https://api.example.com' (not just string)
config.timeout = 3000 // Error: Cannot assign to 'timeout' (readonly)
```
**When to use satisfies vs type annotation:**
- Use `satisfies` when you want validation but need literal types
- Use type annotation (`:`) when you want the variable to be exactly that type
- Use `as const satisfies` for readonly config with validation

View file

@ -18,17 +18,17 @@ type EventHandler = {
}
const handler: EventHandler = {
event: 'click', // OK
event: 'click', // OK
handler: () => {}
}
const badHandler: EventHandler = {
event: 'clck', // Typo - no error
event: 'clck', // Typo - no error
handler: () => {}
}
function addEventListener(event: string, handler: () => void): void { }
addEventListener('onlcick', () => {}) // Typo compiles fine
function addEventListener(event: string, handler: () => void): void {}
addEventListener('onlcick', () => {}) // Typo compiles fine
```
**Correct (template literal type validates pattern):**
@ -43,12 +43,12 @@ type EventHandler = {
}
const handler: EventHandler = {
event: 'onClick', // OK
event: 'onClick', // OK
handler: () => {}
}
const badHandler: EventHandler = {
event: 'onClck', // Error: Type '"onClck"' is not assignable to type 'EventHandlerName'
event: 'onClck', // Error: Type '"onClck"' is not assignable to type 'EventHandlerName'
handler: () => {}
}
```
@ -63,10 +63,10 @@ function setWidth(element: HTMLElement, width: CSSValue): void {
element.style.width = width
}
setWidth(div, '100px') // OK
setWidth(div, '2.5rem') // OK
setWidth(div, '100') // Error: Type '"100"' is not assignable to type 'CSSValue'
setWidth(div, '100pixels') // Error
setWidth(div, '100px') // OK
setWidth(div, '2.5rem') // OK
setWidth(div, '100') // Error: Type '"100"' is not assignable to type 'CSSValue'
setWidth(div, '100pixels') // Error
```
**For API route patterns:**
@ -80,10 +80,10 @@ function fetchResource(route: APIRoute): Promise<Response> {
return fetch(route)
}
fetchResource('/api/v1/users') // OK
fetchResource('/api/v2/orders') // OK
fetchResource('/api/v3/users') // Error: 'v3' not in APIVersion
fetchResource('/users') // Error: doesn't match pattern
fetchResource('/api/v1/users') // OK
fetchResource('/api/v2/orders') // OK
fetchResource('/api/v3/users') // Error: 'v3' not in APIVersion
fetchResource('/users') // Error: doesn't match pattern
```
**Combining with mapped types:**

View file

@ -16,7 +16,7 @@ async function enrichUsers(userIds: string[]): Promise<EnrichedUser[]> {
const enrichedUsers: EnrichedUser[] = []
for (const userId of userIds) {
const user = await fetchUser(userId) // Waits for each request
const user = await fetchUser(userId) // Waits for each request
const profile = await fetchProfile(userId)
enrichedUsers.push({ ...user, profile })
}
@ -32,10 +32,7 @@ async function enrichUsers(userIds: string[]): Promise<EnrichedUser[]> {
async function enrichUsers(userIds: string[]): Promise<EnrichedUser[]> {
const enrichedUsers = await Promise.all(
userIds.map(async (userId) => {
const [user, profile] = await Promise.all([
fetchUser(userId),
fetchProfile(userId),
])
const [user, profile] = await Promise.all([fetchUser(userId), fetchProfile(userId)])
return { ...user, profile }
})
)
@ -56,10 +53,7 @@ async function enrichUsers(userIds: string[]): Promise<EnrichedUser[]> {
const batch = userIds.slice(i, i + BATCH_SIZE)
const batchResults = await Promise.all(
batch.map(async (userId) => {
const [user, profile] = await Promise.all([
fetchUser(userId),
fetchProfile(userId),
])
const [user, profile] = await Promise.all([fetchUser(userId), fetchProfile(userId)])
return { ...user, profile }
})
)
@ -71,6 +65,7 @@ async function enrichUsers(userIds: string[]): Promise<EnrichedUser[]> {
```
**When sequential loop await is acceptable:**
- Each iteration depends on the previous result
- API strictly requires sequential calls
- Processing order affects correctness

View file

@ -53,6 +53,7 @@ async function getUser(userId: string): Promise<User> {
```
**When async IS needed:**
- Multiple sequential await statements
- Try/catch around await (use `return await` here)
- Conditional await logic

View file

@ -13,8 +13,8 @@ Start async operations immediately but defer `await` until the value is actually
```typescript
async function processOrder(orderId: string): Promise<OrderResult> {
const order = await fetchOrder(orderId) // Blocks here
const config = await loadProcessingConfig() // Waits for order first, unnecessarily
const order = await fetchOrder(orderId) // Blocks here
const config = await loadProcessingConfig() // Waits for order first, unnecessarily
// config doesn't depend on order — these could run in parallel
if (order.priority === 'express') {
@ -28,10 +28,10 @@ async function processOrder(orderId: string): Promise<OrderResult> {
```typescript
async function processOrder(orderId: string): Promise<OrderResult> {
const orderPromise = fetchOrder(orderId) // Start immediately
const config = await loadProcessingConfig() // Runs while order fetches
const orderPromise = fetchOrder(orderId) // Start immediately
const config = await loadProcessingConfig() // Runs while order fetches
const order = await orderPromise // Now await when needed
const order = await orderPromise // Now await when needed
if (order.priority === 'express') {
return processExpress(order, config)
@ -61,7 +61,7 @@ async function loadUserContent(userId: string): Promise<Content> {
settingsPromise,
featuresPromise,
ordersPromise,
prefsPromise,
prefsPromise
])
return { user, settings, features, orders, prefs }

View file

@ -15,14 +15,14 @@ Explicit return types on async functions catch mismatches at the function bounda
async function fetchUserOrders(userId: string) {
const response = await fetch(`/api/users/${userId}/orders`)
if (!response.ok) {
return null // Implicit: Promise<Order[] | null>
return null // Implicit: Promise<Order[] | null>
}
return response.json() // Implicit: Promise<any>
return response.json() // Implicit: Promise<any>
}
// Caller has unclear type: Promise<any>
const orders = await fetchUserOrders('123')
orders.map(o => o.id) // No type error even if orders is null
orders.map((o) => o.id) // No type error even if orders is null
```
**Correct (explicit Promise type):**
@ -45,7 +45,7 @@ async function fetchUserOrders(userId: string): Promise<Order[] | null> {
// Caller knows the exact type
const orders = await fetchUserOrders('123')
if (orders) {
orders.map(o => o.id) // Type-safe access
orders.map((o) => o.id) // Type-safe access
}
```
@ -60,7 +60,7 @@ async function fetchUserOrders(userId: string): Promise<Result<Order[]>> {
if (!response.ok) {
return { ok: false, error: new Error(`HTTP ${response.status}`) }
}
const orders = await response.json() as Order[]
const orders = (await response.json()) as Order[]
return { ok: true, value: orders }
} catch (error) {
return { ok: false, error: error as Error }
@ -69,6 +69,7 @@ async function fetchUserOrders(userId: string): Promise<Result<Order[]>> {
```
**Benefits:**
- Errors caught at function definition, not call sites
- Better IDE autocomplete for consumers
- Self-documenting API contracts

View file

@ -13,9 +13,9 @@ Sequential `await` statements create request waterfalls—each operation waits f
```typescript
async function loadDashboard(userId: string): Promise<Dashboard> {
const user = await fetchUser(userId) // 200ms
const orders = await fetchOrders(userId) // 300ms
const notifications = await fetchNotifications(userId) // 150ms
const user = await fetchUser(userId) // 200ms
const orders = await fetchOrders(userId) // 300ms
const notifications = await fetchNotifications(userId) // 150ms
// Total: 650ms (sequential)
return { user, orders, notifications }
@ -27,9 +27,9 @@ async function loadDashboard(userId: string): Promise<Dashboard> {
```typescript
async function loadDashboard(userId: string): Promise<Dashboard> {
const [user, orders, notifications] = await Promise.all([
fetchUser(userId), // 200ms ─┐
fetchOrders(userId), // 300ms ─┼─ Run in parallel
fetchNotifications(userId), // 150ms ─┘
fetchUser(userId), // 200ms ─┐
fetchOrders(userId), // 300ms ─┼─ Run in parallel
fetchNotifications(userId) // 150ms ─┘
])
// Total: 300ms (max of all operations)
@ -44,18 +44,19 @@ async function loadDashboard(userId: string): Promise<Dashboard> {
const results = await Promise.allSettled([
fetchUser(userId),
fetchOrders(userId),
fetchNotifications(userId),
fetchNotifications(userId)
])
return {
user: results[0].status === 'fulfilled' ? results[0].value : null,
orders: results[1].status === 'fulfilled' ? results[1].value : [],
notifications: results[2].status === 'fulfilled' ? results[2].value : [],
notifications: results[2].status === 'fulfilled' ? results[2].value : []
}
}
```
**When sequential is correct:**
- Operations have data dependencies (need result A to make request B)
- Rate limiting requires sequential requests
- Order of execution matters for side effects

View file

@ -18,7 +18,7 @@ function createDataProcessor(largeDataset: DataRecord[]): () => void {
return function processNext(): void {
// This closure retains reference to largeDataset
// even though it only needs processedIds
const next = largeDataset.find(r => !processedIds.has(r.id))
const next = largeDataset.find((r) => !processedIds.has(r.id))
if (next) {
processedIds.add(next.id)
sendToServer(next)
@ -28,7 +28,7 @@ function createDataProcessor(largeDataset: DataRecord[]): () => void {
// largeDataset (100MB) stays in memory as long as processNext exists
const processor = createDataProcessor(hugeDataset)
setInterval(processor, 1000) // Runs forever, 100MB never freed
setInterval(processor, 1000) // Runs forever, 100MB never freed
```
**Correct (closure captures only what it needs):**
@ -36,9 +36,8 @@ setInterval(processor, 1000) // Runs forever, 100MB never freed
```typescript
function createDataProcessor(largeDataset: DataRecord[]): () => void {
// Build a queue of just the IDs and a lookup for individual records
const pendingQueue: string[] = largeDataset.map(r => r.id)
const getRecord = (id: string): DataRecord | undefined =>
largeDataset.find(r => r.id === id)
const pendingQueue: string[] = largeDataset.map((r) => r.id)
const getRecord = (id: string): DataRecord | undefined => largeDataset.find((r) => r.id === id)
// Release the array reference — closure only captures pendingQueue and getRecord
// Caller should also release their reference to largeDataset
@ -76,7 +75,7 @@ class Dashboard {
initialize(): void {
window.addEventListener('resize', () => {
this.handleResize() // 'this' keeps entire Dashboard alive
this.handleResize() // 'this' keeps entire Dashboard alive
})
}
}

View file

@ -13,7 +13,7 @@ Global variables and module-level state persist for the application's lifetime.
```typescript
// cache.ts
const userCache = new Map<string, User>() // Never cleared
const userCache = new Map<string, User>() // Never cleared
export function getCachedUser(id: string): User | undefined {
return userCache.get(id)
@ -61,7 +61,7 @@ class LRUCache<K, V> {
}
}
const userCache = new LRUCache<string, User>(1000) // Max 1000 entries
const userCache = new LRUCache<string, User>(1000) // Max 1000 entries
export function getCachedUser(id: string): User | undefined {
return userCache.get(id)

View file

@ -80,7 +80,7 @@ class WebSocketManager {
}
disconnect(): void {
this.abortController.abort() // Removes all listeners at once
this.abortController.abort() // Removes all listeners at once
this.socket.close()
}
}

View file

@ -41,7 +41,7 @@ class DataPoller {
private intervalId: ReturnType<typeof setInterval> | null = null
start(): void {
if (this.intervalId) return // Prevent duplicate intervals
if (this.intervalId) return // Prevent duplicate intervals
this.intervalId = setInterval(() => {
this.data = fetchLatestData()
@ -87,7 +87,7 @@ class AnimationController {
function usePolling(callback: () => void, interval: number): void {
useEffect(() => {
const id = setInterval(callback, interval)
return () => clearInterval(id) // Cleanup on unmount
return () => clearInterval(id) // Cleanup on unmount
}, [callback, interval])
}
@ -96,7 +96,7 @@ function useDebounce<T>(value: T, delay: number): T {
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer) // Clear on value change or unmount
return () => clearTimeout(timer) // Clear on value change or unmount
}, [value, delay])
return debouncedValue

View file

@ -24,7 +24,7 @@ function trackUser(user: User): void {
function removeUser(user: User): void {
// Even after user is "removed" from app state,
// Map still holds reference, preventing GC
userMetadata.delete(user) // Must manually clean up
userMetadata.delete(user) // Must manually clean up
}
// If delete is forgotten, user objects leak forever
@ -77,6 +77,7 @@ function getComputedConfig(config: Config): ComputedConfig {
```
**Limitations of WeakMap:**
- Keys must be objects (not primitives)
- Not iterable (no `.keys()`, `.values()`, `.entries()`)
- No `.size` property

View file

@ -16,7 +16,7 @@ Barrel files (index.ts re-exports) defeat tree-shaking and force bundlers to loa
export * from './string'
export * from './date'
export * from './validation'
export * from './crypto' // Heavy, rarely used
export * from './crypto' // Heavy, rarely used
// consumer.ts
import { formatDate } from '@/utils'
@ -63,6 +63,7 @@ export default {
```
**When barrels are acceptable:**
- Internal modules with few exports (< 10)
- Package entry points for library consumers
- When bundler is configured to optimize them

View file

@ -20,10 +20,12 @@ export interface User {
orders: Order[]
}
export function createUser(): User { /* ... */ }
export function createUser(): User {
/* ... */
}
// order.ts
import { User } from './user' // Circular!
import { User } from './user' // Circular!
export interface Order {
id: string
@ -52,12 +54,16 @@ export interface Order {
// user.ts
import { User, Order } from './types'
export function createUser(): User { /* ... */ }
export function createUser(): User {
/* ... */
}
// order.ts
import { User, Order } from './types'
export function createOrder(user: User): Order { /* ... */ }
export function createOrder(user: User): Order {
/* ... */
}
```
**Alternative (interface segregation):**
@ -74,7 +80,7 @@ import { UserBase } from './user-types'
export interface Order {
id: string
user: UserBase // Only needs base interface, not full User
user: UserBase // Only needs base interface, not full User
}
```

View file

@ -76,8 +76,8 @@ By default, TypeScript loads all `@types/*` packages from `node_modules`. This c
{
"compilerOptions": {
"typeRoots": [
"./types", // Custom declarations first
"./node_modules/@types" // Then @types
"./types", // Custom declarations first
"./node_modules/@types" // Then @types
],
"types": ["node"]
}
@ -85,6 +85,7 @@ By default, TypeScript loads all `@types/*` packages from `node_modules`. This c
```
**Benefits:**
- Prevents type conflicts between similar packages
- Reduces memory usage during compilation
- Faster IDE responsiveness

View file

@ -12,9 +12,9 @@ Dynamic `import()` creates separate chunks that load on demand. Use them for lar
**Incorrect (static import, always loaded):**
```typescript
import { PDFGenerator } from 'pdfkit' // 500KB
import { ExcelExporter } from 'exceljs' // 800KB
import { ChartLibrary } from 'chart.js' // 300KB
import { PDFGenerator } from 'pdfkit' // 500KB
import { ExcelExporter } from 'exceljs' // 800KB
import { ChartLibrary } from 'chart.js' // 300KB
export async function exportReport(format: 'pdf' | 'excel' | 'chart') {
if (format === 'pdf') {

View file

@ -13,8 +13,8 @@ Type-only imports (`import type`) are completely erased during compilation, prev
```typescript
// config.ts
import { DatabaseConfig } from './database' // Loads entire database module
import { Logger } from './logger' // Loads entire logger module
import { DatabaseConfig } from './database' // Loads entire database module
import { Logger } from './logger' // Loads entire logger module
interface AppConfig {
db: DatabaseConfig
@ -73,6 +73,7 @@ import { createUser, type User, type UserRole } from './user'
```
**Benefits:**
- Smaller bundles (unused modules not included)
- Faster cold starts (fewer modules to parse)
- Clearer code intent (types vs runtime values)

View file

@ -13,9 +13,9 @@ Object spread (`...`) creates a new object on each use. In loops, this causes N
```typescript
function enrichOrders(orders: Order[]): EnrichedOrder[] {
return orders.map(order => ({
...order, // Creates new object
...calculateTotals(order), // Spreads another object
return orders.map((order) => ({
...order, // Creates new object
...calculateTotals(order), // Spreads another object
processedAt: new Date()
}))
}
@ -33,7 +33,7 @@ interface EnrichedOrder extends Order {
}
function enrichOrders(orders: Order[]): EnrichedOrder[] {
return orders.map(order => {
return orders.map((order) => {
const totals = calculateTotals(order)
return {
@ -56,21 +56,28 @@ function enrichOrders(orders: Order[]): EnrichedOrder[] {
```typescript
// Incorrect - spreads on every iteration
const result = items.reduce((acc, item) => ({
...acc,
[item.id]: item.value
}), {})
const result = items.reduce(
(acc, item) => ({
...acc,
[item.id]: item.value
}),
{}
)
// O(n²) - each spread copies growing object
// Correct - mutate accumulator
const result = items.reduce((acc, item) => {
acc[item.id] = item.value
return acc
}, {} as Record<string, number>)
const result = items.reduce(
(acc, item) => {
acc[item.id] = item.value
return acc
},
{} as Record<string, number>
)
// O(n) - direct property assignment
```
**When spread is acceptable:**
- Outside hot paths
- Small objects (< 10 properties)
- When immutability is required for state management

View file

@ -16,8 +16,8 @@ function processOrders(orders: Order[], config: AppConfig): ProcessedOrder[] {
const results: ProcessedOrder[] = []
for (const order of orders) {
const tax = order.total * config.tax.rate // Nested access each iteration
const shipping = config.shipping.rates[order.region] // Nested access again
const tax = order.total * config.tax.rate // Nested access each iteration
const shipping = config.shipping.rates[order.region] // Nested access again
results.push({ ...order, tax, shipping, final: order.total + tax + shipping })
}
@ -49,14 +49,16 @@ function processOrders(orders: Order[], config: AppConfig): ProcessedOrder[] {
// Monomorphic — all objects have same shape, V8 ICs optimize this
function sumOrders(orders: Order[]): number {
let total = 0
for (let i = 0; i < orders.length; i++) { // orders.length is fine
total += orders[i].total // Same shape every time
for (let i = 0; i < orders.length; i++) {
// orders.length is fine
total += orders[i].total // Same shape every time
}
return total
}
```
**When to skip this optimization:**
- Arrays under 1,000 items
- Monomorphic objects (same shape/class)
- Non-hot paths executed infrequently

View file

@ -12,25 +12,25 @@ Modern JavaScript includes most common array operations. Native methods are fast
**Incorrect (lodash for native operations):**
```typescript
import _ from 'lodash' // Imports entire library
import _ from 'lodash' // Imports entire library
const activeUsers = _.filter(users, u => u.isActive)
const userNames = _.map(activeUsers, u => u.name)
const firstAdmin = _.find(users, u => u.role === 'admin')
const hasAdmin = _.some(users, u => u.role === 'admin')
const allActive = _.every(users, u => u.isActive)
const userIds = _.uniq(users.map(u => u.id))
const activeUsers = _.filter(users, (u) => u.isActive)
const userNames = _.map(activeUsers, (u) => u.name)
const firstAdmin = _.find(users, (u) => u.role === 'admin')
const hasAdmin = _.some(users, (u) => u.role === 'admin')
const allActive = _.every(users, (u) => u.isActive)
const userIds = _.uniq(users.map((u) => u.id))
```
**Correct (native methods):**
```typescript
const activeUsers = users.filter(u => u.isActive)
const userNames = activeUsers.map(u => u.name)
const firstAdmin = users.find(u => u.role === 'admin')
const hasAdmin = users.some(u => u.role === 'admin')
const allActive = users.every(u => u.isActive)
const userIds = [...new Set(users.map(u => u.id))]
const activeUsers = users.filter((u) => u.isActive)
const userNames = activeUsers.map((u) => u.name)
const firstAdmin = users.find((u) => u.role === 'admin')
const hasAdmin = users.some((u) => u.role === 'admin')
const allActive = users.every((u) => u.isActive)
const userIds = [...new Set(users.map((u) => u.id))]
```
**Native replacements for common Lodash functions:**
@ -48,23 +48,27 @@ function chunk<T>(array: T[], size: number): T[][] {
// _.groupBy
function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
return array.reduce((groups, item) => {
const group = String(item[key])
groups[group] = groups[group] ?? []
groups[group].push(item)
return groups
}, {} as Record<string, T[]>)
return array.reduce(
(groups, item) => {
const group = String(item[key])
groups[group] = groups[group] ?? []
groups[group].push(item)
return groups
},
{} as Record<string, T[]>
)
}
// Object.groupBy (ES2024)
const grouped = Object.groupBy(users, user => user.role)
const grouped = Object.groupBy(users, (user) => user.role)
// _.pick / _.omit
const { password, ...userWithoutPassword } = user // omit
const { id, name } = user // pick
const { password, ...userWithoutPassword } = user // omit
const { id, name } = user // pick
```
**When Lodash is still valuable:**
- `_.debounce`, `_.throttle` - complex timing logic
- `_.cloneDeep` - deep object cloning
- `_.merge` - deep object merging

View file

@ -54,7 +54,7 @@ for (const item of items) {
}
// forEach: when you want functional style (but can't break/return)
items.forEach(item => process(item))
items.forEach((item) => process(item))
// for-in: only for object keys (never for arrays)
for (const key in config) {
@ -64,8 +64,8 @@ for (const key in config) {
// Traditional for: when you need index, or need to modify loop
for (let i = 0; i < items.length; i++) {
if (items[i].id === targetId) {
items[i] = updatedItem // Modifying array
break // Early exit
items[i] = updatedItem // Modifying array
break // Early exit
}
}

View file

@ -15,12 +15,12 @@ Array methods like `.includes()` and `.find()` are O(n) operations. For frequent
const allowedRoles = ['admin', 'editor', 'viewer', 'moderator']
function hasPermission(userRole: string): boolean {
return allowedRoles.includes(userRole) // O(n) every call
return allowedRoles.includes(userRole) // O(n) every call
}
// In a loop, this becomes O(n × m)
function filterAuthorizedUsers(users: User[]): User[] {
return users.filter(user => allowedRoles.includes(user.role))
return users.filter((user) => allowedRoles.includes(user.role))
// 1000 users × 4 roles = 4000 comparisons
}
```
@ -31,11 +31,11 @@ function filterAuthorizedUsers(users: User[]): User[] {
const allowedRoles = new Set(['admin', 'editor', 'viewer', 'moderator'])
function hasPermission(userRole: string): boolean {
return allowedRoles.has(userRole) // O(1) every call
return allowedRoles.has(userRole) // O(1) every call
}
function filterAuthorizedUsers(users: User[]): User[] {
return users.filter(user => allowedRoles.has(user.role))
return users.filter((user) => allowedRoles.has(user.role))
// 1000 users × O(1) = 1000 operations
}
```
@ -44,19 +44,22 @@ function filterAuthorizedUsers(users: User[]): User[] {
```typescript
// Incorrect - O(n) search
const users: User[] = [/* ... */]
const users: User[] = [
/* ... */
]
function findUserById(id: string): User | undefined {
return users.find(u => u.id === id) // Scans entire array
return users.find((u) => u.id === id) // Scans entire array
}
// Correct - O(1) lookup
const userById = new Map<string, User>(users.map(u => [u.id, u]))
const userById = new Map<string, User>(users.map((u) => [u.id, u]))
function findUserById(id: string): User | undefined {
return userById.get(id)
}
```
**When to stick with arrays:**
- Small collections (< 10 items)
- One-time lookups where conversion cost exceeds benefit
- When you need array methods like `.map()`, `.filter()`, `.slice()`

View file

@ -25,7 +25,7 @@ function containsSearchTerm(text: string, term: string): boolean {
}
function formatOrderId(id: number): string {
return ('000000' + id).slice(-6) // Pad to 6 digits
return ('000000' + id).slice(-6) // Pad to 6 digits
}
```
@ -33,9 +33,7 @@ function formatOrderId(id: number): string {
```typescript
function isImageFile(filename: string): boolean {
return filename.endsWith('.jpg') ||
filename.endsWith('.png') ||
filename.endsWith('.gif')
return filename.endsWith('.jpg') || filename.endsWith('.png') || filename.endsWith('.gif')
}
function hasHttpPrefix(url: string): boolean {
@ -58,12 +56,12 @@ function formatOrderId(id: number): string {
const sanitized = input.replaceAll('<', '&lt;').replaceAll('>', '&gt;')
// at() for negative indexing
const lastChar = filename.at(-1) // Last character
const extension = filename.split('.').at(-1) // Last segment
const lastChar = filename.at(-1) // Last character
const extension = filename.split('.').at(-1) // Last segment
// trimStart/trimEnd for directional trimming
const trimmedLeft = ' text '.trimStart() // 'text '
const trimmedRight = ' text '.trimEnd() // ' text'
const trimmedLeft = ' text '.trimStart() // 'text '
const trimmedRight = ' text '.trimEnd() // ' text'
// repeat for string multiplication
const separator = '-'.repeat(40)
@ -71,6 +69,7 @@ const indent = ' '.repeat(depth)
```
**When regex is still needed:**
- Complex pattern matching
- Capture groups
- Case-insensitive matching (`/pattern/i`)

View file

@ -40,7 +40,7 @@ function shipOrder(order: Order | null): void {
```typescript
interface ValidOrder extends Order {
status: 'pending'
items: [OrderItem, ...OrderItem[]] // Non-empty array
items: [OrderItem, ...OrderItem[]] // Non-empty array
}
function assertValidOrder(order: Order | null): asserts order is ValidOrder {
@ -58,7 +58,7 @@ function assertValidOrder(order: Order | null): asserts order is ValidOrder {
function processOrder(order: Order | null): void {
assertValidOrder(order)
// order is now typed as ValidOrder
submitOrder(order) // Type-safe
submitOrder(order) // Type-safe
}
function shipOrder(order: Order | null): void {
@ -85,6 +85,7 @@ function processUser(user: User | null): void {
```
**Benefits:**
- Centralizes validation logic
- Automatic type narrowing after assertion
- Clearer intent than if-throw patterns

View file

@ -19,7 +19,7 @@ const config = {
}
// Type: { apiUrl: string; retries: number; methods: string[] }
function makeRequest(method: 'GET' | 'POST'): void { }
function makeRequest(method: 'GET' | 'POST'): void {}
makeRequest(config.methods[0])
// Error: Argument of type 'string' is not assignable to 'GET' | 'POST'
@ -41,9 +41,9 @@ const config = {
} as const
// Type: { readonly apiUrl: 'https://api.example.com'; readonly retries: 3; readonly methods: readonly ['GET', 'POST'] }
function makeRequest(method: 'GET' | 'POST'): void { }
function makeRequest(method: 'GET' | 'POST'): void {}
makeRequest(config.methods[0]) // Works: 'GET' is assignable to 'GET' | 'POST'
makeRequest(config.methods[0]) // Works: 'GET' is assignable to 'GET' | 'POST'
const STATUS = {
PENDING: 'pending',
@ -51,24 +51,25 @@ const STATUS = {
} as const
// Type: { readonly PENDING: 'pending'; readonly ACTIVE: 'active' }
type StatusType = typeof STATUS[keyof typeof STATUS] // 'pending' | 'active'
type StatusType = (typeof STATUS)[keyof typeof STATUS] // 'pending' | 'active'
```
**For function parameters:**
```typescript
// Incorrect - tuple becomes array
function setCoordinates(coords: [number, number]): void { }
setCoordinates([10, 20]) // Error: number[] not assignable to [number, number]
function setCoordinates(coords: [number, number]): void {}
setCoordinates([10, 20]) // Error: number[] not assignable to [number, number]
// Correct - const preserves tuple
setCoordinates([10, 20] as const) // Works
setCoordinates([10, 20] as const) // Works
// Or inline
function setCoordinates(coords: readonly [number, number]): void { }
function setCoordinates(coords: readonly [number, number]): void {}
```
**When to use const assertions:**
- Configuration objects that shouldn't change
- Enum-like objects with string values
- Array/tuple literals passed to functions expecting specific types

View file

@ -51,7 +51,7 @@ function getStatusMessage(status: OrderStatus): string {
case 'delivered':
return 'Order complete'
default:
return assertNever(status) // Compile error if case missed
return assertNever(status) // Compile error if case missed
}
}
@ -66,7 +66,7 @@ const statusMessages: Record<OrderStatus, string> = {
pending: 'Order received',
processing: 'Preparing your order',
shipped: 'On the way',
delivered: 'Order complete',
delivered: 'Order complete'
// Missing key causes: Property 'cancelled' is missing in type
}
@ -76,6 +76,7 @@ function getStatusMessage(status: OrderStatus): string {
```
**Benefits:**
- Compile-time error when union expands
- Self-documenting: all cases explicitly handled
- Runtime safety via assertNever fallback

View file

@ -21,12 +21,12 @@ With `noUncheckedIndexedAccess` enabled, TypeScript adds `undefined` to the type
```typescript
const users = ['Alice', 'Bob', 'Charlie']
const first = users[0] // Type: string (lies — could be undefined)
console.log(first.toUpperCase()) // No error, but crashes if array is empty
const first = users[0] // Type: string (lies — could be undefined)
console.log(first.toUpperCase()) // No error, but crashes if array is empty
const scores: Record<string, number> = { math: 95 }
const science = scores['science'] // Type: number (lies — key doesn't exist)
console.log(science.toFixed(2)) // No error, but crashes at runtime
const science = scores['science'] // Type: number (lies — key doesn't exist)
console.log(science.toFixed(2)) // No error, but crashes at runtime
```
**Correct (with noUncheckedIndexedAccess):**
@ -42,18 +42,18 @@ console.log(science.toFixed(2)) // No error, but crashes at runtime
```typescript
const users = ['Alice', 'Bob', 'Charlie']
const first = users[0] // Type: string | undefined
console.log(first.toUpperCase()) // Error: 'first' is possibly undefined
const first = users[0] // Type: string | undefined
console.log(first.toUpperCase()) // Error: 'first' is possibly undefined
// Handle the undefined case
if (first) {
console.log(first.toUpperCase()) // OK after narrowing
console.log(first.toUpperCase()) // OK after narrowing
}
const scores: Record<string, number> = { math: 95 }
const science = scores['science'] // Type: number | undefined
const science = scores['science'] // Type: number | undefined
if (science !== undefined) {
console.log(science.toFixed(2)) // OK after narrowing
console.log(science.toFixed(2)) // OK after narrowing
}
```
@ -71,14 +71,15 @@ function getRequired(items: string[], index: number): string {
if (index < 0 || index >= items.length) {
throw new RangeError(`Index ${index} out of bounds`)
}
return items[index]! // Safe — bounds checked above
return items[index]! // Safe — bounds checked above
}
// Array.at() returns T | undefined regardless of this flag
const last = items.at(-1) // Already T | undefined
const last = items.at(-1) // Already T | undefined
```
**When to disable:**
- Legacy codebases with heavy array indexing (migration cost too high)
- Performance-critical inner loops where the narrowing pattern adds overhead

View file

@ -20,7 +20,7 @@ function processApiResponse(data: any): string {
async function fetchData(): Promise<any> {
const response = await fetch('/api/data')
return response.json() // Returns Promise<any>, loses all type info
return response.json() // Returns Promise<any>, loses all type info
}
```
@ -46,7 +46,7 @@ function processApiResponse(data: unknown): string {
if (!isApiResponse(data)) {
throw new Error('Invalid API response')
}
return data.user.name.toUpperCase() // Type-safe access
return data.user.name.toUpperCase() // Type-safe access
}
```
@ -54,7 +54,7 @@ function processApiResponse(data: unknown): string {
```typescript
// Incorrect
const config = JSON.parse(configString) as AppConfig // Unsafe assertion
const config = JSON.parse(configString) as AppConfig // Unsafe assertion
// Correct
function parseConfig(configString: string): AppConfig {
@ -69,6 +69,7 @@ function parseConfig(configString: string): AppConfig {
```
**When any is acceptable:**
- Migrating JavaScript to TypeScript incrementally
- Third-party library workarounds (with `// @ts-expect-error`)
- Truly dynamic code where type is unknowable

View file

@ -15,11 +15,11 @@ With `strictNullChecks`, TypeScript distinguishes between `T`, `T | null`, and `
// tsconfig.json: { "strictNullChecks": false }
function getUser(id: string): User {
return userMap.get(id) // Returns User | undefined, but typed as User
return userMap.get(id) // Returns User | undefined, but typed as User
}
const user = getUser('123')
console.log(user.email) // No error, but crashes if user is undefined
console.log(user.email) // No error, but crashes if user is undefined
```
**Correct (strictNullChecks enabled):**
@ -28,22 +28,22 @@ console.log(user.email) // No error, but crashes if user is undefined
// tsconfig.json: { "strict": true } (includes strictNullChecks)
function getUser(id: string): User | undefined {
return userMap.get(id) // Correctly typed as User | undefined
return userMap.get(id) // Correctly typed as User | undefined
}
const user = getUser('123')
console.log(user.email) // Error: 'user' is possibly 'undefined'
console.log(user.email) // Error: 'user' is possibly 'undefined'
// Must handle the undefined case
if (user) {
console.log(user.email) // Type narrowed to User
console.log(user.email) // Type narrowed to User
}
// Or use optional chaining
console.log(user?.email) // string | undefined
console.log(user?.email) // string | undefined
// Or assert when you're certain
const confirmedUser = getUser('123')! // Non-null assertion (use sparingly)
const confirmedUser = getUser('123')! // Non-null assertion (use sparingly)
```
**Common patterns with strictNullChecks:**

View file

@ -19,12 +19,12 @@ interface User {
}
function handleUserEvent(event: MessageEvent): void {
const user = event.data as User // Unsafe assertion
sendEmail(user.email) // Crashes if data isn't actually a User
const user = event.data as User // Unsafe assertion
sendEmail(user.email) // Crashes if data isn't actually a User
}
function processResponse(data: unknown): User[] {
return data as User[] // No runtime check
return data as User[] // No runtime check
}
```
@ -46,7 +46,7 @@ function handleUserEvent(event: MessageEvent): void {
console.error('Invalid user data received')
return
}
sendEmail(event.data.email) // Type-safe: event.data is User
sendEmail(event.data.email) // Type-safe: event.data is User
}
function processResponse(data: unknown): User[] {
@ -76,9 +76,9 @@ function isSuccess(result: ApiResult): result is SuccessResult {
function handleResult(result: ApiResult): void {
if (isSuccess(result)) {
console.log(result.data.email) // Type narrowed to SuccessResult
console.log(result.data.email) // Type narrowed to SuccessResult
} else {
console.error(result.message) // Type narrowed to ErrorResult
console.error(result.message) // Type narrowed to ErrorResult
}
}
```

View file

@ -52,16 +52,14 @@ tsc # 15s first build, 1-3s subsequent builds
"declaration": true,
"declarationMap": true
},
"references": [
{ "path": "../shared" },
{ "path": "../utils" }
]
"references": [{ "path": "../shared" }, { "path": "../utils" }]
}
```
**Note:** The `composite` flag implies `incremental: true` and requires `declaration: true`.
**When to disable incremental:**
- CI environments where cache isn't preserved between runs
- One-off type-checking scripts

View file

@ -50,7 +50,7 @@ export type OrderStatus = 'pending' | 'shipped' | 'delivered'
export const OrderStatus = {
Pending: 'pending',
Shipped: 'shipped',
Delivered: 'delivered',
Delivered: 'delivered'
} as const satisfies Record<string, OrderStatus>
// Module-level functions instead of namespace
@ -80,6 +80,7 @@ class UserService {
```
**When NOT to use this flag:**
- Projects using a bundler (esbuild, swc, Vite) that supports enum transformation
- Libraries that need to support both bundled and unbundled consumers
- Codebases with extensive enum usage where migration cost is high

View file

@ -34,14 +34,7 @@ TypeScript walks through all included directories to discover files. Overly broa
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"coverage",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**"
]
"exclude": ["node_modules", "dist", "coverage", "**/*.test.ts", "**/*.spec.ts", "**/__tests__/**"]
}
```
@ -77,6 +70,7 @@ tsc --explainFiles
```
**Common files to exclude:**
- `node_modules` (always)
- Build output directories (`dist`, `build`, `out`)
- Test files for production builds

View file

@ -29,7 +29,7 @@ export const enum Status {
// user.ts
import { Status } from './constants'
const status = Status.Active // Requires reading constants.ts to inline
const status = Status.Active // Requires reading constants.ts to inline
```
**Correct (single-file transpilable):**
@ -47,14 +47,15 @@ const status = Status.Active // Requires reading constants.ts to inline
```typescript
// constants.ts
export enum Status { // Regular enum, not const enum
export enum Status {
// Regular enum, not const enum
Active = 'active',
Inactive = 'inactive'
}
// user.ts
import { Status } from './constants'
const status = Status.Active // Reference preserved, no cross-file read
const status = Status.Active // Reference preserved, no cross-file read
```
**Build pipeline integration:**
@ -70,6 +71,7 @@ export default {
```
**Code patterns blocked by isolatedModules:**
- `const enum` (use regular `enum` or union types instead)
- `export =` / `import =` syntax
- Re-exporting types without `type` keyword

View file

@ -47,11 +47,13 @@ export function calculateTotal(items: CartItem[]): number {
```
**What requires annotation under isolatedDeclarations:**
- Exported function return types
- Exported variable types when not inferable from a literal
- Exported class method return types
**What does NOT need annotation:**
- Local variables and functions (not exported)
- Function parameters (already required by TypeScript)
- Exports initialized with literals (`export const MAX = 100` is fine)

View file

@ -66,9 +66,7 @@ my-app/
"declaration": true,
"outDir": "dist"
},
"references": [
{ "path": "../shared" }
],
"references": [{ "path": "../shared" }],
"include": ["src/**/*"]
}
```
@ -90,6 +88,7 @@ tsc --build # Builds only changed projects
```
**Benefits:**
- Parallel compilation of independent projects
- Change in `shared/` only rebuilds dependents
- Declaration files used as API boundaries

View file

@ -57,6 +57,7 @@ The `skipLibCheck` option skips type-checking of declaration files (`.d.ts`). Si
This only skips checking the default library files (lib.d.ts), not third-party declarations.
**When to disable skipLibCheck:**
- Debugging type conflicts between declaration files
- Publishing a library where you want to verify `.d.ts` output
- Encountering mysterious type errors that might originate in declarations

View file

@ -25,7 +25,7 @@ type Handler<T> = (event: T) => void
// Without strictFunctionTypes, TypeScript uses bidirectional
// (bivariant) checking - comparing structures both ways
const handler: Handler<MouseEvent> = (e: Event) => { } // Allowed but unsafe
const handler: Handler<MouseEvent> = (e: Event) => {} // Allowed but unsafe
```
**Correct (fast variance checking):**
@ -43,7 +43,7 @@ type Handler<T> = (event: T) => void
// With strictFunctionTypes, TypeScript uses contravariant
// checking for parameters - faster and type-safe
const handler: Handler<MouseEvent> = (e: Event) => { } // Error: Event is not MouseEvent
const handler: Handler<MouseEvent> = (e: Event) => {} // Error: Event is not MouseEvent
```
**Note:** The `strict` flag enables `strictFunctionTypes` along with other strict options. Enable `strict` for all new projects.
@ -53,12 +53,12 @@ const handler: Handler<MouseEvent> = (e: Event) => { } // Error: Event is not M
```typescript
// Use method syntax for intentional bivariance
interface EventEmitter<T> {
emit(event: T): void // Method syntax = bivariant
emit(event: T): void // Method syntax = bivariant
}
// vs property syntax for contravariance
interface StrictEmitter<T> {
emit: (event: T) => void // Property syntax = contravariant
emit: (event: T) => void // Property syntax = contravariant
}
```

View file

@ -28,7 +28,7 @@ type CachedResponse<T> = PaginatedResponse<{
}>
// Usage creates 4+ levels of nesting
function fetchUsers(): CachedResponse<User> { }
function fetchUsers(): CachedResponse<User> {}
// Compiler must resolve: CachedResponse<User> → PaginatedResponse<...> → ApiResponse<...>
```
@ -57,7 +57,7 @@ interface ApiResponse<T> {
// Compose at usage site instead of nesting
type UserListResponse = ApiResponse<PaginatedData<User> & CacheInfo>
function fetchUsers(): UserListResponse { }
function fetchUsers(): UserListResponse {}
// Single-level generic instantiation
```

View file

@ -14,9 +14,15 @@ Union type checking is quadratic — TypeScript compares each union member pairw
```typescript
// Auto-generated from GraphQL schema — 200+ event types
type AnalyticsEvent =
| 'page_view' | 'button_click' | 'form_submit' | 'scroll_depth'
| 'video_play' | 'video_pause' | 'video_complete' | 'ad_impression'
// ... 200 more event types from analytics schema
| 'page_view'
| 'button_click'
| 'form_submit'
| 'scroll_depth'
| 'video_play'
| 'video_pause'
| 'video_complete'
| 'ad_impression'
// ... 200 more event types from analytics schema
// 200 members = 40,000 pairwise comparisons per usage
```
@ -25,7 +31,7 @@ type AnalyticsEvent =
```typescript
type AnalyticsEvent = string & { readonly __brand: 'AnalyticsEvent' }
const VALID_EVENTS = new Set(['page_view', 'button_click', 'form_submit', /* ... */])
const VALID_EVENTS = new Set(['page_view', 'button_click', 'form_submit' /* ... */])
function createEvent(name: string): AnalyticsEvent {
if (!VALID_EVENTS.has(name)) {
@ -48,6 +54,7 @@ type AppEvent = UserEvent | PageEvent | FormEvent
```
**When flat unions are fine:**
- Small unions (< 20 members) have negligible cost
- Unions of primitive literals used in few places
- `string | number | boolean` style utility unions

View file

@ -15,13 +15,13 @@ Explicit return types accelerate compilation by eliminating inference overhead.
export function fetchUserProfile(userId: string) {
// Compiler must analyze entire function body to infer return type
return fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => ({
.then((res) => res.json())
.then((data) => ({
id: data.id as string,
name: data.name as string,
email: data.email as string,
createdAt: new Date(data.created_at),
permissions: data.permissions as Permission[],
permissions: data.permissions as Permission[]
}))
}
// Inferred: Promise<{ id: string; name: string; email: string; createdAt: Date; permissions: Permission[] }>
@ -40,23 +40,25 @@ interface UserProfile {
export function fetchUserProfile(userId: string): Promise<UserProfile> {
return fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => ({
.then((res) => res.json())
.then((data) => ({
id: data.id,
name: data.name,
email: data.email,
createdAt: new Date(data.created_at),
permissions: data.permissions,
permissions: data.permissions
}))
}
```
**When to skip explicit return types:**
- Private/internal functions with simple returns
- Arrow functions in local scope
- Functions where the return type is obvious (e.g., `(): void`)
**Benefits:**
- Declaration files use named type instead of expanded inline type
- Faster incremental compilation when function body changes
- Better error messages pointing to return type mismatch

View file

@ -14,11 +14,7 @@ Inline conditional types are re-evaluated on every function call. Extracting the
```typescript
function processResponse<T>(
response: T
): T extends { data: infer D }
? D extends Array<infer Item>
? Item[]
: D
: never {
): T extends { data: infer D } ? (D extends Array<infer Item> ? Item[] : D) : never {
// Compiler re-computes this complex conditional on every call
return response.data
}
@ -50,6 +46,7 @@ function getFirstItem<T>(collection: T): UnwrapArray<T> {
```
**Benefits:**
- Type alias acts as a cache boundary
- Reduces duplicate computation across multiple call sites
- Improves IDE responsiveness for autocomplete

View file

@ -34,6 +34,7 @@ interface ExtendedOrder extends Order, Timestamps {
```
**When to use intersections:**
- Combining function types or primitives (interfaces cannot extend these)
- Creating mapped or conditional types
- One-off type combinations not reused elsewhere

View file

@ -17,13 +17,7 @@ type DeepPartial<T> = {
}
// No depth limit - deeply nested objects cause exponential expansion
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue }
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }
// Infinite recursion potential
```
@ -31,21 +25,20 @@ type JSONValue =
```typescript
type DeepPartial<T, Depth extends number[] = []> = Depth['length'] extends 5
? T // Stop at depth 5
? T // Stop at depth 5
: {
[P in keyof T]?: T[P] extends object
? DeepPartial<T[P], [...Depth, 1]>
: T[P]
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P], [...Depth, 1]> : T[P]
}
type JSONValue<Depth extends number[] = []> = Depth['length'] extends 10
? unknown
: | string
| number
| boolean
| null
| JSONValue<[...Depth, 1]>[]
| { [key: string]: JSONValue<[...Depth, 1]> }
:
| string
| number
| boolean
| null
| JSONValue<[...Depth, 1]>[]
| { [key: string]: JSONValue<[...Depth, 1]> }
```
**Alternative (use built-in utilities):**
@ -59,6 +52,7 @@ type Config = Partial<AppConfig>
```
**When unbounded recursion is acceptable:**
- Types with guaranteed shallow depth (max 2-3 levels)
- Internal types not exposed in public APIs

View file

@ -33,11 +33,7 @@ type ComplexTransform<T> = {
**Correct (composed utility types):**
```typescript
type TransformValue<T> = T extends Date
? string
: T extends number
? string
: T
type TransformValue<T> = T extends Date ? string : T extends number ? string : T
type TransformObject<T> = {
[K in keyof T]: TransformProperty<T[K]>
@ -51,20 +47,20 @@ type TransformProperty<T> = T extends Function
? TransformObject<T>
: TransformValue<T>
type TransformArray<T> = T extends object
? TransformObject<T>[]
: T[]
type TransformArray<T> = T extends object ? TransformObject<T>[] : T[]
// Each utility is cached independently
type TransformedUser = TransformObject<User>
```
**Benefits:**
- Each small utility type is cached separately
- Easier to debug type errors
- More reusable across the codebase
**When complex mapped types are acceptable:**
- Internal utility types used in few places
- Types that genuinely require complex logic

View file

@ -10,9 +10,13 @@ const execFileAsync = promisify(execFile)
*/
export function isGitRepo(path: string): boolean {
try {
if (!existsSync(path) || !statSync(path).isDirectory()) return false
if (!existsSync(path) || !statSync(path).isDirectory()) {
return false
}
// .git dir or file (for worktrees) or bare repo
if (existsSync(join(path, '.git'))) return true
if (existsSync(join(path, '.git'))) {
return true
}
// Might be a bare repo — ask git
const result = execSync('git rev-parse --is-inside-work-tree', {
cwd: path,
@ -73,7 +77,9 @@ function getGitConfigValue(path: string, key: string): string {
function normalizeUsername(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ''
if (!trimmed) {
return ''
}
const localPart = trimmed.includes('@') ? trimmed.split('@')[0] : trimmed
return localPart.replace(/^\d+\+/, '')
@ -85,7 +91,9 @@ function getGhLogin(): string {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
if (apiLogin) return normalizeUsername(apiLogin)
if (apiLogin) {
return normalizeUsername(apiLogin)
}
} catch {
// Fall through to auth status parsing
}
@ -100,7 +108,9 @@ function getGhLogin(): string {
const activeAccountMatch = output.match(
/Active account:\s+true[\s\S]*?account\s+([A-Za-z0-9-]+)/
)
if (activeAccountMatch?.[1]) return normalizeUsername(activeAccountMatch[1])
if (activeAccountMatch?.[1]) {
return normalizeUsername(activeAccountMatch[1])
}
const accountMatch = output.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)
return normalizeUsername(accountMatch?.[1] ?? '')
@ -154,10 +164,18 @@ export function getDefaultBaseRef(path: string): string {
// Fall through to explicit remote branch probes.
}
if (hasGitRef(path, 'refs/remotes/origin/main')) return 'origin/main'
if (hasGitRef(path, 'refs/remotes/origin/master')) return 'origin/master'
if (hasGitRef(path, 'refs/heads/main')) return 'main'
if (hasGitRef(path, 'refs/heads/master')) return 'master'
if (hasGitRef(path, 'refs/remotes/origin/main')) {
return 'origin/main'
}
if (hasGitRef(path, 'refs/remotes/origin/master')) {
return 'origin/master'
}
if (hasGitRef(path, 'refs/heads/main')) {
return 'main'
}
if (hasGitRef(path, 'refs/heads/master')) {
return 'master'
}
return 'origin/main'
}
@ -184,17 +202,27 @@ async function getDefaultBaseRefAsync(path: string): Promise<string> {
// Fall through to explicit remote branch probes.
}
if (await hasGitRefAsync(path, 'refs/remotes/origin/main')) return 'origin/main'
if (await hasGitRefAsync(path, 'refs/remotes/origin/master')) return 'origin/master'
if (await hasGitRefAsync(path, 'refs/heads/main')) return 'main'
if (await hasGitRefAsync(path, 'refs/heads/master')) return 'master'
if (await hasGitRefAsync(path, 'refs/remotes/origin/main')) {
return 'origin/main'
}
if (await hasGitRefAsync(path, 'refs/remotes/origin/master')) {
return 'origin/master'
}
if (await hasGitRefAsync(path, 'refs/heads/main')) {
return 'main'
}
if (await hasGitRefAsync(path, 'refs/heads/master')) {
return 'master'
}
return 'origin/main'
}
export async function searchBaseRefs(path: string, query: string, limit = 25): Promise<string[]> {
const normalizedQuery = normalizeRefSearchQuery(query)
if (!normalizedQuery) return []
if (!normalizedQuery) {
return []
}
try {
const { stdout } = await execFileAsync(
@ -218,7 +246,9 @@ export async function searchBaseRefs(path: string, query: string, limit = 25): P
.map((line) => line.trim())
.filter((line) => line && line !== 'origin/HEAD')
.filter((line) => {
if (seen.has(line)) return false
if (seen.has(line)) {
return false
}
seen.add(line)
return true
})

View file

@ -20,7 +20,9 @@ export async function getStatus(worktreePath: string): Promise<GitStatusEntry[]>
)
for (const line of stdout.split('\n')) {
if (!line) continue
if (!line) {
continue
}
if (line.startsWith('1 ') || line.startsWith('2 ')) {
// Changed entries: "1 XY sub mH mI mW hH path" or "2 XY sub mH mI mW hH X\tscore\tpath\torigPath"

View file

@ -12,7 +12,9 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
const blocks = output.trim().split('\n\n')
for (const block of blocks) {
if (!block.trim()) continue
if (!block.trim()) {
continue
}
const lines = block.trim().split('\n')
let path = ''
@ -69,7 +71,9 @@ export function addWorktree(
baseBranch?: string
): void {
const args = ['worktree', 'add', '-b', branch, worktreePath]
if (baseBranch) args.push(baseBranch)
if (baseBranch) {
args.push(baseBranch)
}
execFileSync('git', args, {
cwd: repoPath,
encoding: 'utf-8',
@ -86,7 +90,9 @@ export async function removeWorktree(
force = false
): Promise<void> {
const args = ['worktree', 'remove', worktreePath]
if (force) args.push('--force')
if (force) {
args.push('--force')
}
await execFileAsync('git', args, {
cwd: repoPath,
encoding: 'utf-8'

View file

@ -16,7 +16,9 @@ function parseOrcaYaml(content: string): OrcaHooks | null {
// Match top-level "scripts:" block
const scriptsMatch = content.match(/^scripts:\s*$/m)
if (!scriptsMatch) return null
if (!scriptsMatch) {
return null
}
const afterScripts = content.slice(scriptsMatch.index! + scriptsMatch[0].length)
const lines = afterScripts.split('\n')
@ -26,7 +28,9 @@ function parseOrcaYaml(content: string): OrcaHooks | null {
for (const line of lines) {
// Another top-level key (not indented) — stop parsing scripts block
if (/^\S/.test(line) && line.trim().length > 0) break
if (/^\S/.test(line) && line.trim().length > 0) {
break
}
// Indented key like " setup: |" or " archive: |"
const keyMatch = line.match(/^ (setup|archive):\s*\|?\s*$/)
@ -42,7 +46,7 @@ function parseOrcaYaml(content: string): OrcaHooks | null {
// Content line (indented by 4+ spaces under a key)
if (currentKey && line.startsWith(' ')) {
currentValue += line.slice(4) + '\n'
currentValue += `${line.slice(4)}\n`
}
}
@ -51,7 +55,9 @@ function parseOrcaYaml(content: string): OrcaHooks | null {
hooks.scripts[currentKey] = currentValue.trimEnd()
}
if (!hooks.scripts.setup && !hooks.scripts.archive) return null
if (!hooks.scripts.setup && !hooks.scripts.archive) {
return null
}
return hooks
}
@ -60,7 +66,9 @@ function parseOrcaYaml(content: string): OrcaHooks | null {
*/
export function loadHooks(repoPath: string): OrcaHooks | null {
const yamlPath = join(repoPath, 'orca.yaml')
if (!existsSync(yamlPath)) return null
if (!existsSync(yamlPath)) {
return null
}
try {
const content = readFileSync(yamlPath, 'utf-8')
@ -103,7 +111,9 @@ export function getEffectiveHooks(repo: Repo): OrcaHooks | null {
}
}
if (!hooks.scripts.setup && !hooks.scripts.archive) return null
if (!hooks.scripts.setup && !hooks.scripts.archive) {
return null
}
return hooks
}

View file

@ -109,6 +109,7 @@ function createWindow(): BrowserWindow {
// File drag-and-drop: the preload script handles the drop event (because
// File.path is only available there), sends paths here, and we relay to renderer.
ipcMain.removeAllListeners('terminal:file-dropped-from-preload')
ipcMain.on('terminal:file-dropped-from-preload', (_event, args: { paths: string[] }) => {
if (!mainWindow.isDestroyed()) {
for (const p of args.paths) {
@ -286,7 +287,10 @@ app.whenReady().then(() => {
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow()
registerRepoHandlers(mainWindow, store!)
registerWorktreeHandlers(mainWindow, store!)
registerPtyHandlers(mainWindow)
setupAutoUpdater(mainWindow, { onBeforeQuit: () => store?.flush() })
}
})
})

View file

@ -13,6 +13,13 @@ let loadGeneration = 0
const ptyLoadGeneration = new Map<string, number>()
export function registerPtyHandlers(mainWindow: BrowserWindow): void {
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
ipcMain.removeHandler('pty:spawn')
ipcMain.removeHandler('pty:resize')
ipcMain.removeHandler('pty:kill')
ipcMain.removeAllListeners('pty:write')
// Kill orphaned PTY processes from previous page loads when the renderer reloads.
// PTYs tagged with the current loadGeneration were spawned during THIS page load
// and must be preserved — only kill PTYs from earlier generations.

View file

@ -1,4 +1,5 @@
import { BrowserWindow, dialog, ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import { dialog, ipcMain } from 'electron'
import { randomUUID } from 'crypto'
import type { Store } from '../persistence'
import type { Repo } from '../../shared/types'
@ -12,6 +13,17 @@ import {
} from '../git/repo'
export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void {
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
ipcMain.removeHandler('repos:list')
ipcMain.removeHandler('repos:add')
ipcMain.removeHandler('repos:remove')
ipcMain.removeHandler('repos:update')
ipcMain.removeHandler('repos:pickFolder')
ipcMain.removeHandler('repos:getGitUsername')
ipcMain.removeHandler('repos:getBaseRefDefault')
ipcMain.removeHandler('repos:searchBaseRefs')
ipcMain.handle('repos:list', () => {
return store.getRepos()
})
@ -23,7 +35,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
// Check if already added
const existing = store.getRepos().find((r) => r.path === args.path)
if (existing) return existing
if (existing) {
return existing
}
const repo: Repo = {
id: randomUUID(),
@ -55,7 +69,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
) => {
const updated = store.updateRepo(args.repoId, args.updates)
if (updated) notifyReposChanged(mainWindow)
if (updated) {
notifyReposChanged(mainWindow)
}
return updated
}
)
@ -64,19 +80,25 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) return null
if (result.canceled || result.filePaths.length === 0) {
return null
}
return result.filePaths[0]
})
ipcMain.handle('repos:getGitUsername', (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) return ''
if (!repo) {
return ''
}
return getGitUsername(repo.path)
})
ipcMain.handle('repos:getBaseRefDefault', async (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) return 'origin/main'
if (!repo) {
return 'origin/main'
}
return getBaseRefDefault(repo.path)
})
@ -84,7 +106,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
'repos:searchBaseRefs',
async (_event, args: { repoId: string; query: string; limit?: number }) => {
const repo = store.getRepo(args.repoId)
if (!repo) return []
if (!repo) {
return []
}
return searchBaseRefs(repo.path, args.query, args.limit ?? 25)
}
)

View file

@ -1,4 +1,5 @@
import { BrowserWindow, ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import { ipcMain } from 'electron'
import { join, basename } from 'path'
import type { Store } from '../persistence'
import type { Worktree, WorktreeMeta } from '../../shared/types'
@ -7,6 +8,15 @@ import { getGitUsername, getDefaultBaseRef, getAvailableBranchName } from '../gi
import { getEffectiveHooks, loadHooks, runHook, hasHooksFile } from '../hooks'
export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void {
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
ipcMain.removeHandler('worktrees:listAll')
ipcMain.removeHandler('worktrees:list')
ipcMain.removeHandler('worktrees:create')
ipcMain.removeHandler('worktrees:remove')
ipcMain.removeHandler('worktrees:updateMeta')
ipcMain.removeHandler('hooks:check')
ipcMain.handle('worktrees:listAll', async () => {
const repos = store.getRepos()
const allWorktrees: Worktree[] = []
@ -25,7 +35,9 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) return []
if (!repo) {
return []
}
const gitWorktrees = await listWorktrees(repo.path)
return gitWorktrees.map((gw) => {
@ -39,7 +51,9 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
'worktrees:create',
async (_event, args: { repoId: string; name: string; baseBranch?: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) throw new Error(`Repo not found: ${args.repoId}`)
if (!repo) {
throw new Error(`Repo not found: ${args.repoId}`)
}
const settings = store.getSettings()
@ -78,7 +92,9 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
// Re-list to get the freshly created worktree info
const gitWorktrees = await listWorktrees(repo.path)
const created = gitWorktrees.find((gw) => gw.path === worktreePath)
if (!created) throw new Error('Worktree created but not found in listing')
if (!created) {
throw new Error('Worktree created but not found in listing')
}
const worktreeId = `${repo.id}::${worktreePath}`
const metaUpdates: Partial<WorktreeMeta> =
@ -108,7 +124,9 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
async (_event, args: { worktreeId: string; force?: boolean }) => {
const { repoId, worktreePath } = parseWorktreeId(args.worktreeId)
const repo = store.getRepo(repoId)
if (!repo) throw new Error(`Repo not found: ${repoId}`)
if (!repo) {
throw new Error(`Repo not found: ${repoId}`)
}
// Run archive hook before removal
const hooks = getEffectiveHooks(repo)
@ -142,7 +160,9 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
ipcMain.handle('hooks:check', (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) return { hasHooks: false, hooks: null }
if (!repo) {
return { hasHooks: false, hooks: null }
}
const has = hasHooksFile(repo.path)
const hooks = has ? loadHooks(repo.path) : null
@ -178,7 +198,9 @@ function mergeWorktree(
function parseWorktreeId(worktreeId: string): { repoId: string; worktreePath: string } {
const sepIdx = worktreeId.indexOf('::')
if (sepIdx === -1) throw new Error(`Invalid worktreeId: ${worktreeId}`)
if (sepIdx === -1) {
throw new Error(`Invalid worktreeId: ${worktreeId}`)
}
return {
repoId: worktreeId.slice(0, sepIdx),
worktreePath: worktreeId.slice(sepIdx + 2)
@ -196,7 +218,9 @@ function formatWorktreeRemovalError(error: unknown, worktreePath: string, force:
? `Failed to force delete worktree at ${worktreePath}.`
: `Failed to delete worktree at ${worktreePath}.`
if (!(error instanceof Error)) return fallback
if (!(error instanceof Error)) {
return fallback
}
const errorWithStreams = error as Error & { stderr?: string; stdout?: string }
const details = [errorWithStreams.stderr, errorWithStreams.stdout, error.message]

View file

@ -44,12 +44,16 @@ export class Store {
}
private scheduleSave(): void {
if (this.writeTimer) clearTimeout(this.writeTimer)
if (this.writeTimer) {
clearTimeout(this.writeTimer)
}
this.writeTimer = setTimeout(() => {
this.writeTimer = null
try {
const dir = dirname(DATA_FILE)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const tmpFile = `${DATA_FILE}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
renameSync(tmpFile, DATA_FILE)
@ -67,7 +71,9 @@ export class Store {
getRepo(id: string): Repo | undefined {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) return undefined
if (!repo) {
return undefined
}
return this.hydrateRepo(repo)
}
@ -93,7 +99,9 @@ export class Store {
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef'>>
): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) return null
if (!repo) {
return null
}
Object.assign(repo, updates)
this.scheduleSave()
return this.hydrateRepo(repo)
@ -199,7 +207,9 @@ export class Store {
}
try {
const dir = dirname(DATA_FILE)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const tmpFile = `${DATA_FILE}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
renameSync(tmpFile, DATA_FILE)

View file

@ -4,8 +4,12 @@ let cachedFonts: string[] | null = null
let fontsPromise: Promise<string[]> | null = null
export async function listSystemFontFamilies(): Promise<string[]> {
if (cachedFonts) return cachedFonts
if (fontsPromise) return fontsPromise
if (cachedFonts) {
return cachedFonts
}
if (fontsPromise) {
return fontsPromise
}
fontsPromise = loadSystemFontFamilies()
.then((fonts) => {
@ -28,8 +32,12 @@ export function warmSystemFontFamilies(): void {
}
function loadSystemFontFamilies(): Promise<string[]> {
if (process.platform === 'darwin') return listMacFonts()
if (process.platform === 'win32') return listWindowsFonts()
if (process.platform === 'darwin') {
return listMacFonts()
}
if (process.platform === 'win32') {
return listWindowsFonts()
}
return listLinuxFonts()
}
@ -37,11 +45,11 @@ function listMacFonts(): Promise<string[]> {
return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then(
(output) => {
const parsed = JSON.parse(output) as {
SPFontsDataType?: Array<{
typefaces?: Array<{
SPFontsDataType?: {
typefaces?: {
family?: string
}>
}>
}[]
}[]
}
return uniqueSorted(
@ -98,7 +106,7 @@ function execFileText(command: string, args: string[], maxBuffer: number): Promi
})
}
function uniqueSorted(values: Array<string | undefined>): string[] {
function uniqueSorted(values: (string | undefined)[]): string[] {
return Array.from(
new Set(
values

View file

@ -8,6 +8,7 @@ let mainWindowRef: BrowserWindow | null = null
let currentStatus: UpdateStatus = { state: 'idle' }
let userInitiatedCheck = false
let onBeforeQuitCleanup: (() => void) | null = null
let autoUpdaterInitialized = false
function sendStatus(status: UpdateStatus): void {
currentStatus = status
@ -85,6 +86,11 @@ export function setupAutoUpdater(
// This is safe since we don't publish prerelease versions.
autoUpdater.allowPrerelease = true
if (autoUpdaterInitialized) {
return
}
autoUpdaterInitialized = true
autoUpdater.on('checking-for-update', () => {
sendStatus({ state: 'checking', userInitiated: userInitiatedCheck || undefined })
})

View file

@ -166,7 +166,7 @@ function App(): React.JSX.Element {
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
}, [settings?.theme])
}, [settings])
// Refresh GitHub data (PR/issue status) when window regains focus
useEffect(() => {

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'
import { ChevronUp, ChevronDown, X, CaseSensitive, Regex } from 'lucide-react'
import type { SearchAddon } from '@xterm/addon-search'
interface TerminalSearchProps {
type TerminalSearchProps = {
isOpen: boolean
onClose: () => void
searchAddon: SearchAddon | null
@ -68,7 +68,9 @@ export default function TerminalSearch({
[onClose, findNext, findPrevious]
)
if (!isOpen) return null
if (!isOpen) {
return null
}
return (
<div

View file

@ -7,7 +7,7 @@ import '@/lib/monaco-setup'
import { cn } from '@/lib/utils'
import type { GitStatusEntry } from '../../../../shared/types'
interface DiffSection {
type DiffSection = {
entry: GitStatusEntry
originalContent: string
modifiedContent: string
@ -38,7 +38,9 @@ export default function CombinedDiffViewer({
// Filter to only staged and unstaged (not untracked)
const changed = entries.filter((e) => e.area !== 'untracked')
if (cancelled) return
if (cancelled) {
return
}
// Initialize sections
const initialSections: DiffSection[] = changed.map((entry) => ({
@ -66,7 +68,9 @@ export default function CombinedDiffViewer({
})
)
if (cancelled) return
if (cancelled) {
return
}
setSections((prev) =>
prev.map((section, i) => ({
@ -136,7 +140,7 @@ export default function CombinedDiffViewer({
return (
<div
key={section.entry.path + ':' + section.entry.area}
key={`${section.entry.path}:${section.entry.area}`}
className="border-b border-border"
>
{/* Section header */}

View file

@ -4,7 +4,7 @@ import { Columns2, Rows2 } from 'lucide-react'
import { useAppStore } from '@/store'
import '@/lib/monaco-setup'
interface DiffViewerProps {
type DiffViewerProps = {
originalContent: string
modifiedContent: string
language: string

View file

@ -1,7 +1,7 @@
import type { CSSProperties } from 'react'
import { cn } from '@/lib/utils'
interface RepoDotLabelProps {
type RepoDotLabelProps = {
name: string
color: string
className?: string

View file

@ -5,7 +5,7 @@ import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { cn } from '@/lib/utils'
interface TreeNode {
type TreeNode = {
name: string
path: string // absolute path
relativePath: string
@ -13,7 +13,7 @@ interface TreeNode {
depth: number
}
interface DirCache {
type DirCache = {
children: TreeNode[]
loading: boolean
}
@ -28,10 +28,14 @@ export default function FileExplorer(): React.JSX.Element {
// Find active worktree path
const worktreePath = useMemo(() => {
if (!activeWorktreeId) return null
if (!activeWorktreeId) {
return null
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) return wt.path
if (wt) {
return wt.path
}
}
return null
}, [activeWorktreeId, worktreesByRepo])
@ -48,7 +52,9 @@ export default function FileExplorer(): React.JSX.Element {
// Load directory contents
const loadDir = useCallback(
async (dirPath: string, depth: number) => {
if (dirCache[dirPath]?.children.length > 0 || dirCache[dirPath]?.loading) return
if (dirCache[dirPath]?.children.length > 0 || dirCache[dirPath]?.loading) {
return
}
setDirCache((prev) => ({
...prev,
@ -90,7 +96,9 @@ export default function FileExplorer(): React.JSX.Element {
// Load root when worktree changes
useEffect(() => {
if (!worktreePath) return
if (!worktreePath) {
return
}
setDirCache({})
void loadDir(worktreePath, -1)
}, [worktreePath]) // eslint-disable-line react-hooks/exhaustive-deps
@ -109,13 +117,17 @@ export default function FileExplorer(): React.JSX.Element {
// Flatten tree into visible rows
const flatRows = useMemo(() => {
if (!worktreePath) return []
if (!worktreePath) {
return []
}
const result: TreeNode[] = []
const addChildren = (parentPath: string): void => {
const cached = dirCache[parentPath]
if (!cached?.children) return
if (!cached?.children) {
return
}
for (const child of cached.children) {
result.push(child)
@ -139,7 +151,9 @@ export default function FileExplorer(): React.JSX.Element {
const handleClick = useCallback(
(node: TreeNode) => {
if (!activeWorktreeId) return
if (!activeWorktreeId) {
return
}
if (node.isDirectory) {
toggleDir(activeWorktreeId, node.path)

View file

@ -63,16 +63,22 @@ export default function SourceControl(): React.JSX.Element {
// Find active worktree path
const worktreePath = useMemo(() => {
if (!activeWorktreeId) return null
if (!activeWorktreeId) {
return null
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) return wt.path
if (wt) {
return wt.path
}
}
return null
}, [activeWorktreeId, worktreesByRepo])
const fetchStatus = useCallback(async () => {
if (!activeWorktreeId || !worktreePath) return
if (!activeWorktreeId || !worktreePath) {
return
}
try {
const entries = (await window.api.git.status({ worktreePath })) as GitStatusEntry[]
setGitStatus(activeWorktreeId, entries)
@ -86,7 +92,9 @@ export default function SourceControl(): React.JSX.Element {
void fetchStatus()
pollRef.current = setInterval(() => void fetchStatus(), 3000)
return () => {
if (pollRef.current) clearInterval(pollRef.current)
if (pollRef.current) {
clearInterval(pollRef.current)
}
}
}, [fetchStatus])
@ -110,15 +118,20 @@ export default function SourceControl(): React.JSX.Element {
const toggleSection = useCallback((section: string) => {
setCollapsedSections((prev) => {
const next = new Set(prev)
if (next.has(section)) next.delete(section)
else next.add(section)
if (next.has(section)) {
next.delete(section)
} else {
next.add(section)
}
return next
})
}, [])
const handleStage = useCallback(
async (filePath: string) => {
if (!worktreePath) return
if (!worktreePath) {
return
}
try {
await window.api.git.stage({ worktreePath, filePath })
void fetchStatus()
@ -131,7 +144,9 @@ export default function SourceControl(): React.JSX.Element {
const handleUnstage = useCallback(
async (filePath: string) => {
if (!worktreePath) return
if (!worktreePath) {
return
}
try {
await window.api.git.unstage({ worktreePath, filePath })
void fetchStatus()
@ -144,7 +159,9 @@ export default function SourceControl(): React.JSX.Element {
const handleDiscard = useCallback(
async (filePath: string) => {
if (!worktreePath) return
if (!worktreePath) {
return
}
try {
await window.api.git.discard({ worktreePath, filePath })
void fetchStatus()
@ -156,13 +173,17 @@ export default function SourceControl(): React.JSX.Element {
)
const handleViewAllChanges = useCallback(() => {
if (!activeWorktreeId || !worktreePath) return
if (!activeWorktreeId || !worktreePath) {
return
}
openAllDiffs(activeWorktreeId, worktreePath)
}, [activeWorktreeId, worktreePath, openAllDiffs])
const handleOpenDiff = useCallback(
(entry: GitStatusEntry) => {
if (!activeWorktreeId) return
if (!activeWorktreeId) {
return
}
const language = detectLanguage(entry.path)
const absolutePath = worktreePath ? `${worktreePath}/${entry.path}` : entry.path
openDiff(activeWorktreeId, absolutePath, entry.path, language, entry.area === 'staged')
@ -202,7 +223,9 @@ export default function SourceControl(): React.JSX.Element {
{SECTION_ORDER.map((area) => {
const items = grouped[area]
if (items.length === 0) return null
if (items.length === 0) {
return null
}
const isCollapsed = collapsedSections.has(area)
return (

View file

@ -142,9 +142,7 @@ export function GeneralPane({
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Updates</h2>
<p className="text-xs text-muted-foreground">
Current version: {appVersion ?? '…'}
</p>
<p className="text-xs text-muted-foreground">Current version: {appVersion ?? '…'}</p>
</div>
<div className="flex items-center gap-3">

View file

@ -104,7 +104,9 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
)
const handleCreate = useCallback(async () => {
if (!repoId || !name.trim()) return
if (!repoId || !name.trim()) {
return
}
setCreating(true)
try {
const wt = await createWorktree(repoId, name.trim())
@ -161,7 +163,9 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
resetTimeoutRef.current = null
}
if (isOpen) return
if (isOpen) {
return
}
resetTimeoutRef.current = window.setTimeout(() => {
setRepoId('')
@ -182,10 +186,14 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
// Focus and select name input when suggestion is applied
React.useEffect(() => {
if (!isOpen || !repoId || !suggestedName) return
if (!isOpen || !repoId || !suggestedName) {
return
}
requestAnimationFrame(() => {
const input = nameInputRef.current
if (!input) return
if (!input) {
return
}
input.focus()
input.select()
})
@ -318,7 +326,9 @@ function getSuggestedSpaceName(
worktreesByRepo: Record<string, { path: string }[]>,
nestWorkspaces: boolean
): string {
if (!repoId) return SPACE_NAMES[0]
if (!repoId) {
return SPACE_NAMES[0]
}
const usedNames = new Set<string>()
const repoWorktrees = worktreesByRepo[repoId] ?? []
@ -365,7 +375,9 @@ function findRepoIdForWorktree(
worktreeId: string | null,
worktreesByRepo: Record<string, { id: string }[]>
): string | null {
if (!worktreeId) return null
if (!worktreeId) {
return null
}
for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) {
if (worktrees.some((worktree) => worktree.id === worktreeId)) {

View file

@ -42,8 +42,12 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const handleOpenChange = useCallback(
(open: boolean) => {
if (open || isDeleting) return
if (worktreeId) clearWorktreeDeleteState(worktreeId)
if (open || isDeleting) {
return
}
if (worktreeId) {
clearWorktreeDeleteState(worktreeId)
}
closeModal()
},
[clearWorktreeDeleteState, closeModal, isDeleting, worktreeId]
@ -51,7 +55,9 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const handleDelete = useCallback(
async (force = false) => {
if (!worktreeId) return
if (!worktreeId) {
return
}
closeModal()
const result = await removeWorktree(worktreeId, force)
if (!result.ok) {
@ -69,8 +75,9 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
<DialogHeader>
<DialogTitle className="text-sm">Delete Worktree</DialogTitle>
<DialogDescription className="text-xs">
Remove <span className="break-all font-medium text-foreground">{worktree?.displayName}</span> from
git and delete its working tree folder.
Remove{' '}
<span className="break-all font-medium text-foreground">{worktree?.displayName}</span>{' '}
from git and delete its working tree folder.
</DialogDescription>
</DialogHeader>

View file

@ -40,7 +40,9 @@ const GroupControls = React.memo(function GroupControls() {
type="single"
value={groupBy}
onValueChange={(v) => {
if (v) setGroupBy(v as typeof groupBy)
if (v) {
setGroupBy(v as typeof groupBy)
}
}}
variant="outline"
size="sm"

View file

@ -20,7 +20,9 @@ const SidebarHeader = React.memo(function SidebarHeader() {
variant="ghost"
size="icon-xs"
onClick={() => {
if (!canCreateWorktree) return
if (!canCreateWorktree) {
return
}
openModal('create-worktree')
}}
aria-label="Add worktree"

View file

@ -22,9 +22,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
<div className="flex items-center border-t border-sidebar-border bg-primary/10">
<button
onClick={() =>
updateStatus.state === 'downloaded'
? window.api.updater.quitAndInstall()
: undefined
updateStatus.state === 'downloaded' ? window.api.updater.quitAndInstall() : undefined
}
className="flex items-center gap-1.5 flex-1 min-w-0 px-2 py-1.5 text-[11px] font-medium text-primary hover:bg-primary/15 transition-colors cursor-pointer"
>

View file

@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
type Status = 'active' | 'working' | 'permission' | 'inactive'
interface StatusIndicatorProps {
type StatusIndicatorProps = {
status: Status
className?: string
}

View file

@ -1,103 +1,22 @@
import React, { useMemo, useCallback, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
ChevronDown,
CircleCheckBig,
CircleDot,
CircleX,
FolderGit2,
GitPullRequest,
Plus
} from 'lucide-react'
import { ChevronDown, CircleX, Plus } from 'lucide-react'
import { useAppStore } from '@/store'
import WorktreeCard from './WorktreeCard'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { Worktree, Repo } from '../../../../shared/types'
function branchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
// ── Row types for the virtualizer ───────────────────────────────
type GroupHeaderRow = {
type: 'header'
key: string
label: string
count: number
tone: string
icon: React.ComponentType<{ className?: string }>
repo?: Repo
}
type WorktreeRow = { type: 'item'; worktree: Worktree; repo: Repo | undefined }
type Row = GroupHeaderRow | WorktreeRow
type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
const PR_GROUP_META: Record<
PRGroupKey,
{
label: string
icon: React.ComponentType<{ className?: string }>
tone: string
}
> = {
done: {
label: 'Done',
icon: CircleCheckBig,
tone: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
},
'in-review': {
label: 'In review',
icon: GitPullRequest,
tone: 'border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-200'
},
'in-progress': {
label: 'In progress',
icon: CircleDot,
tone: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-200'
},
closed: {
label: 'Closed',
icon: CircleX,
tone: 'border-zinc-500/20 bg-zinc-500/10 text-zinc-600 dark:text-zinc-300'
}
}
function getPRGroupKey(
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null
): PRGroupKey {
const repo = repoMap.get(worktree.repoId)
const branch = branchName(worktree.branch)
const cacheKey = repo ? `${repo.path}::${branch}` : ''
const prEntry =
cacheKey && prCache
? (prCache[cacheKey] as { data?: { state?: string } } | undefined)
: undefined
const pr = prEntry?.data
if (!pr) return 'in-progress'
if (pr.state === 'merged') return 'done'
if (pr.state === 'closed') return 'closed'
if (pr.state === 'draft') return 'in-progress'
return 'in-review'
}
function getGroupKeyForWorktree(
groupBy: 'none' | 'repo' | 'pr-status',
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null
): string | null {
if (groupBy === 'none') return null
if (groupBy === 'repo') return `repo:${worktree.repoId}`
return `pr:${getPRGroupKey(worktree, repoMap, prCache)}`
}
import {
branchName,
getPRGroupKey,
type PRGroupKey,
type Row,
PR_GROUP_META,
PR_GROUP_ORDER,
REPO_GROUP_META,
getGroupKeyForWorktree
} from './worktree-list-groups'
const WorktreeList = React.memo(function WorktreeList() {
// ── Granular selectors (each is a primitive or shallow-stable ref) ──
@ -123,7 +42,9 @@ const WorktreeList = React.memo(function WorktreeList() {
const repoMap = useMemo(() => {
const m = new Map<string, Repo>()
for (const r of repos) m.set(r.id, r)
for (const r of repos) {
m.set(r.id, r)
}
return m
}, [repos])
@ -185,8 +106,11 @@ const WorktreeList = React.memo(function WorktreeList() {
const toggleGroup = useCallback((key: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}, [])
@ -216,16 +140,20 @@ const WorktreeList = React.memo(function WorktreeList() {
key = `pr:${prGroup}`
label = PR_GROUP_META[prGroup].label
}
if (!grouped.has(key)) grouped.set(key, { label, items: [], repo })
if (!grouped.has(key)) {
grouped.set(key, { label, items: [], repo })
}
grouped.get(key)!.items.push(w)
}
const orderedGroups: Array<[string, { label: string; items: Worktree[]; repo?: Repo }]> = []
const orderedGroups: [string, { label: string; items: Worktree[]; repo?: Repo }][] = []
if (groupBy === 'pr-status') {
for (const prGroup of PR_GROUP_ORDER) {
const key = `pr:${prGroup}`
const group = grouped.get(key)
if (group) orderedGroups.push([key, group])
if (group) {
orderedGroups.push([key, group])
}
}
} else {
orderedGroups.push(...Array.from(grouped.entries()))
@ -241,8 +169,8 @@ const WorktreeList = React.memo(function WorktreeList() {
key,
label: group.label,
count: group.items.length,
tone: 'border-border/70 bg-background/70 text-foreground',
icon: FolderGit2,
tone: REPO_GROUP_META.tone,
icon: REPO_GROUP_META.icon,
repo
}
: (() => {
@ -267,7 +195,7 @@ const WorktreeList = React.memo(function WorktreeList() {
}
return result
}, [groupBy, worktrees, repoMap, prCache, collapsedGroups, tabsByWorktree])
}, [groupBy, worktrees, repoMap, prCache, collapsedGroups])
// ── TanStack Virtual ──────────────────────────────────────────
const virtualizer = useVirtualizer({
@ -282,7 +210,9 @@ const WorktreeList = React.memo(function WorktreeList() {
})
React.useEffect(() => {
if (!pendingRevealWorktreeId) return
if (!pendingRevealWorktreeId) {
return
}
// Uncollapse the group containing the target worktree
if (groupBy !== 'none') {
@ -291,7 +221,9 @@ const WorktreeList = React.memo(function WorktreeList() {
const groupKey = getGroupKeyForWorktree(groupBy, targetWorktree, repoMap, prCache)
if (groupKey) {
setCollapsedGroups((prev) => {
if (!prev.has(groupKey)) return prev
if (!prev.has(groupKey)) {
return prev
}
const next = new Set(prev)
next.delete(groupKey)
return next
@ -413,7 +345,9 @@ const WorktreeList = React.memo(function WorktreeList() {
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (row.repo) handleCreateForRepo(row.repo.id)
if (row.repo) {
handleCreateForRepo(row.repo.id)
}
}}
>
<Plus className="size-3" />

View file

@ -35,7 +35,9 @@ export default function Sidebar(): React.JSX.Element {
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isResizing.current) return
if (!isResizing.current) {
return
}
const delta = e.clientX - startX.current
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta))
setSidebarWidth(next)

View file

@ -0,0 +1,103 @@
import { CircleCheckBig, CircleDot, CircleX, FolderGit2, GitPullRequest } from 'lucide-react'
import type React from 'react'
import type { Repo, Worktree } from '../../../../shared/types'
export function branchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
export type GroupHeaderRow = {
type: 'header'
key: string
label: string
count: number
tone: string
icon: React.ComponentType<{ className?: string }>
repo?: Repo
}
export type WorktreeRow = { type: 'item'; worktree: Worktree; repo: Repo | undefined }
export type Row = GroupHeaderRow | WorktreeRow
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
export const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
export const PR_GROUP_META: Record<
PRGroupKey,
{
label: string
icon: React.ComponentType<{ className?: string }>
tone: string
}
> = {
done: {
label: 'Done',
icon: CircleCheckBig,
tone: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
},
'in-review': {
label: 'In review',
icon: GitPullRequest,
tone: 'border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-200'
},
'in-progress': {
label: 'In progress',
icon: CircleDot,
tone: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-200'
},
closed: {
label: 'Closed',
icon: CircleX,
tone: 'border-zinc-500/20 bg-zinc-500/10 text-zinc-600 dark:text-zinc-300'
}
}
export const REPO_GROUP_META = {
tone: 'border-border/70 bg-background/70 text-foreground',
icon: FolderGit2
} as const
export function getPRGroupKey(
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null
): PRGroupKey {
const repo = repoMap.get(worktree.repoId)
const branch = branchName(worktree.branch)
const cacheKey = repo ? `${repo.path}::${branch}` : ''
const prEntry =
cacheKey && prCache
? (prCache[cacheKey] as { data?: { state?: string } } | undefined)
: undefined
const pr = prEntry?.data
if (!pr) {
return 'in-progress'
}
if (pr.state === 'merged') {
return 'done'
}
if (pr.state === 'closed') {
return 'closed'
}
if (pr.state === 'draft') {
return 'in-progress'
}
return 'in-review'
}
export function getGroupKeyForWorktree(
groupBy: 'none' | 'repo' | 'pr-status',
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null
): string | null {
if (groupBy === 'none') {
return null
}
if (groupBy === 'repo') {
return `repo:${worktree.repoId}`
}
return `pr:${getPRGroupKey(worktree, repoMap, prCache)}`
}

View file

@ -221,7 +221,18 @@ export function useTerminalKeyboardShortcuts({
window.removeEventListener('keydown', onAltBackspace, { capture: true })
window.removeEventListener('keydown', onShiftEnter, { capture: true })
}
}, [isActive])
}, [
isActive,
managerRef,
paneTransportsRef,
expandedPaneIdRef,
setExpandedPane,
restoreExpandedLayout,
refreshPaneSizes,
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen
])
}
type FontZoomDeps = {
@ -277,5 +288,5 @@ export function useTerminalFontZoom({
/* ignore */
}
})
}, [isActive])
}, [isActive, managerRef, paneFontSizesRef, settingsRef])
}

View file

@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
type UseTerminalPaneContextMenuDeps = {

View file

@ -48,7 +48,9 @@ export function useIpcEvents(): void {
checkingToastId = undefined
}
} else if (status.state === 'available') {
if (checkingToastId) toast.dismiss(checkingToastId)
if (checkingToastId) {
toast.dismiss(checkingToastId)
}
checkingToastId = undefined
} else if (status.state === 'downloaded') {
toast.success(`Version ${status.version} is ready to install.`, {
@ -72,12 +74,18 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onTerminalZoom((direction) => {
const { activeView } = useAppStore.getState()
if (activeView === 'terminal') return
if (activeView === 'terminal') {
return
}
const current = window.api.ui.getZoomLevel()
let next: number
if (direction === 'in') next = current + ZOOM_STEP
else if (direction === 'out') next = current - ZOOM_STEP
else next = 0
if (direction === 'in') {
next = current + ZOOM_STEP
} else if (direction === 'out') {
next = current - ZOOM_STEP
} else {
next = 0
}
applyUIZoom(next)
window.api.ui.set({ uiZoomLevel: next })
})

View file

@ -27,23 +27,43 @@ function containsAny(title: string, words: string[]): boolean {
}
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
if (!title) return null
if (!title) {
return null
}
// Gemini CLI symbols are the most specific and should take precedence.
if (title.includes(GEMINI_PERMISSION)) return 'permission'
if (title.includes(GEMINI_WORKING)) return 'working'
if (title.includes(GEMINI_IDLE)) return 'idle'
if (title.includes(GEMINI_PERMISSION)) {
return 'permission'
}
if (title.includes(GEMINI_WORKING)) {
return 'working'
}
if (title.includes(GEMINI_IDLE)) {
return 'idle'
}
if (containsBrailleSpinner(title)) return 'working'
if (containsBrailleSpinner(title)) {
return 'working'
}
if (containsAgentName(title)) {
if (containsAny(title, ['action required', 'permission', 'waiting'])) return 'permission'
if (containsAny(title, ['ready', 'idle', 'done'])) return 'idle'
if (containsAny(title, ['working', 'thinking', 'running'])) return 'working'
if (containsAny(title, ['action required', 'permission', 'waiting'])) {
return 'permission'
}
if (containsAny(title, ['ready', 'idle', 'done'])) {
return 'idle'
}
if (containsAny(title, ['working', 'thinking', 'running'])) {
return 'working'
}
// Claude Code title prefixes: ". " = working, "* " = idle
if (title.startsWith('. ')) return 'working'
if (title.startsWith('* ')) return 'idle'
if (title.startsWith('. ')) {
return 'working'
}
if (title.startsWith('* ')) {
return 'idle'
}
return 'idle'
}

View file

@ -6,10 +6,14 @@ const GH_ITEM_PATH_RE = /^\/[^/]+\/[^/]+\/(?:issues|pull)\/(\d+)(?:\/)?$/i
*/
export function parseGitHubIssueOrPRNumber(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) return null
if (!trimmed) {
return null
}
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
if (/^\d+$/.test(numeric)) return Number.parseInt(numeric, 10)
if (/^\d+$/.test(numeric)) {
return Number.parseInt(numeric, 10)
}
let url: URL
try {
@ -18,10 +22,14 @@ export function parseGitHubIssueOrPRNumber(input: string): number | null {
return null
}
if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) return null
if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) {
return null
}
const match = GH_ITEM_PATH_RE.exec(url.pathname)
if (!match) return null
if (!match) {
return null
}
return Number.parseInt(match[1], 10)
}

View file

@ -19,7 +19,9 @@ export type EffectiveTerminalAppearance = {
}
export function getSystemPrefersDark(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return true
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
@ -29,7 +31,9 @@ export function getBuiltinTheme(name: string): ITheme | null {
export function getTerminalThemePreview(name: string): ITheme | null {
const theme = getTheme(name)
if (theme) return theme
if (theme) {
return theme
}
return getTheme(DEFAULT_TERMINAL_THEME_DARK)
}
@ -67,7 +71,9 @@ export function resolveEffectiveTerminalAppearance(
export function normalizeColor(value: string | undefined, fallback: string): string {
const trimmed = value?.trim()
if (!trimmed) return fallback
if (!trimmed) {
return fallback
}
return trimmed
}
@ -135,8 +141,12 @@ export function colorToCss(
color: { r: number; g: number; b: number; a?: number } | string | undefined,
fallback: string
): string {
if (!color) return fallback
if (typeof color === 'string') return color
if (!color) {
return fallback
}
if (typeof color === 'string') {
return color
}
const alpha = typeof color.a === 'number' ? color.a / 255 : 1
return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`
}
@ -161,11 +171,15 @@ const PALETTE_KEYS = [
] as const
export function terminalPalettePreview(theme: ITheme | null): string[] {
if (!theme) return []
if (!theme) {
return []
}
const swatches: string[] = []
for (const key of PALETTE_KEYS) {
const color = theme[key]
if (color) swatches.push(color)
if (color) {
swatches.push(color)
}
}
return swatches
}

View file

@ -2,7 +2,7 @@ import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { GlobalSettings } from '../../../../shared/types'
export interface SettingsSlice {
export type SettingsSlice = {
settings: GlobalSettings | null
fetchSettings: () => Promise<void>
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>