mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
598f7c17fa
commit
d9facf1b9c
84 changed files with 873 additions and 528 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()`
|
||||
|
|
|
|||
|
|
@ -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('<', '<').replaceAll('>', '>')
|
||||
|
||||
// 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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RepoDotLabelProps {
|
||||
type RepoDotLabelProps = {
|
||||
name: string
|
||||
color: string
|
||||
className?: string
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
|
|||
|
||||
type Status = 'active' | 'working' | 'permission' | 'inactive'
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
type StatusIndicatorProps = {
|
||||
status: Status
|
||||
className?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
103
src/renderer/src/components/sidebar/worktree-list-groups.ts
Normal file
103
src/renderer/src/components/sidebar/worktree-list-groups.ts
Normal 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)}`
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue