Skip to content

Commit 341e618

Browse files
gnoviawanclaudekilo-code-bot[bot]
authored
release: v0.2.3 (#36)
* fix: preserve scroll position and resolve idle lag issues (#29) * fix: resolve idle lag/hang issue when app is idle Root causes addressed: - Windows CWD polling was doing unnecessary work (returns null) - Polling services continued during idle (CWD, Git trackers) - WebGL context recovery lacked debouncing - Activity state updates were excessive during rapid output Changes: - Disable CWD polling on Windows (AC-US2-03) - Add visibility-aware polling (AC-US2-01, AC-US2-02) - Implement debounced WebGL recovery with 300ms delay (AC-US3-01, AC-US3-02) - Throttle activity state updates to 100ms intervals (AC-US1-01) - Add visibility IPC for main-renderer communication - Add comprehensive tests for visibility handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: preserve scroll position and fix cursor duplication on pane move Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review findings - Add missing VisibilityApi type import in preload/index.ts - Remove unused getCachedScrollPosition import - Fix ineffective WebGL isContextLost check (canvas.getContext returns null) - Fix disposal order test to use actual WebGL addon instance - Use isWindows field instead of process.platform in cwd-tracker - Convert real setTimeout to fake timers in cwd-tracker tests - Rewrite git-tracker visibility test to exercise actual behavior - Track setTimeout IDs in visibility/power-resume handlers - Add destroyTerminal function to clean up scrollPositionCache Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add onPowerResume API and fix activity tracking - Add onPowerResume to SystemApi interface and preload implementation - Add VisibilityApi to Window.api type declaration - Replace updateTerminalActivityBatched with updateTerminalActivity and updateTerminalLastActivityTimestamp calls (method doesn't exist in store) - Move constants to module level for better performance - Fix WebGL recovery to only recreate when context was actually lost - Add webglContextLostRef to track context loss state - Fix timer leaks in git-tracker tests with try/finally pattern - Remove non-existent updateTerminalActivityBatched from test mock Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: test state leaks and WebGL recovery logic - Reset lastCreatedWebglInstance in beforeEach to prevent stale state leaks - Fix WebGL recovery condition: check !webglAddonRef.current (addon is null after onContextLoss) instead of webglAddonRef.current - Cancel pending auto-recovery timeout before recreating to avoid double-creation - Add afterEach to restore visibility state and timers in visibility tests - Rename terminal to terminalRecord to avoid shadowing outer closure variable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: timer cleanup and WebGL recovery state reset - Add afterEach to WebGL context loss recovery tests to reset fake timers - Move vi.useFakeTimers() before render in context loss test - Reset webglRecoveryAttemptsRef and webglContextLostRef in cleanup - Set loadWebglAddonRef.current = null in cleanup to release closure references Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix: use dashes in artifact names for auto-updater compatibility (#31) Change artifact naming from dots to dashes to match what existing v0.2.2 clients expect in latest.yml. This ensures users on v0.2.2 can auto-update to future releases. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(changelog): prevent duplicate PR references in release notes (#32) When squash merging PRs, GitHub includes the PR number in commit titles (e.g., `feat: something (#25)`). Git-cliff was also adding another PR link, resulting in duplicate references like `(#25) ([#25](...))`. This fix detects existing PR references in commit messages and skips adding the duplicate link. Also updated from deprecated `commit.github.pr_number` to `commit.remote.pr_number` for future compatibility. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve performance issues causing app lag and idle hangs (#33) * fix: resolve performance issues causing app lag and idle hangs - Git tracker: increase poll interval from 2s to 5s and add concurrent poll guard to prevent overlapping git status processes - Terminal store: add ptyIdIndex Map for O(1) lookups instead of linear scan on every data event, add batched activity update method to combine hasActivity + timestamp into single set() call - ConnectedTerminal: cache ptyId-to-terminalId mapping and use batched store updates, reducing store notifications and array copies by half - Auto-save: skip activity-only store changes to prevent unnecessary scrollback serialization on every activity toggle - Editor persistence: coalesce three independent debounce timers into one shared timer to prevent up to 3x redundant disk writes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review feedback on performance changes - Remove healthStatus from auto-save structural check since serializeTerminalsForProject does not persist it, preventing spurious writeDebounced calls - Advance skip-polling test timer to 6500ms so the setInterval actually fires and the visibility guard is exercised - Derive STATUS_POLL_INTERVAL_MS from GIT_COMMAND_TIMEOUT_MS + 1000 to prevent cadence overlap when git commands hit their timeout - Wrap pollAllStatus body in outer try/catch to prevent unhandled rejections from synchronous errors; replace Promise.allSettled with Promise.all since inner promises already catch - Simplify pendingActivityUpdateRef to { id: string } since hasActivity is always true at write sites and never read in the timeout path - Destructure ptyIdIndex alongside terminals in closeTerminal to eliminate redundant get() call - Add @deprecated JSDoc to legacy updateTerminalActivity and updateTerminalLastActivityTimestamp methods Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update src/renderer/hooks/useTerminalAutoSave.ts Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> * Update src/renderer/hooks/useTerminalAutoSave.ts Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> * fix: show dotfiles in project explorer (#34) * fix: show dotfiles in project explorer Remove the hidden file filter that was hiding all files/folders starting with "." from the file explorer. The hardcoded ignores (.git, .DS_Store, node_modules, etc.) still apply. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve empty interface lint error in filesystem types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused ReadDirectoryOptions type and dead options parameter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove duplicate line causing parse error in useTerminalAutoSave Also ignore .auto-claude directory in eslint config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version to v0.2.3 (#35) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
1 parent 5668e1d commit 341e618

26 files changed

+1246
-129
lines changed

cliff.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ body = """
1717
{% for group, commits in commits | group_by(attribute="group") %}
1818
### {{ group | striptags | trim | upper_first }}
1919
{% for commit in commits %}
20+
{%- set msg = commit.message | upper_first -%}
21+
{%- set pr_in_msg = msg is containing("(#" ) -%}
2022
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}\
21-
{{ commit.message | upper_first }}\
22-
{% if commit.github.pr_number %} ([#{{ commit.github.pr_number }}](https://github.com/{{ get_env(name="GITHUB_REPO", default="OWNER/REPO") }}/pull/{{ commit.github.pr_number }})){% endif %}\
23+
{{ msg }}\
24+
{% if commit.remote.pr_number and not pr_in_msg %} ([#{{ commit.remote.pr_number }}](https://github.com/{{ get_env(name="GITHUB_REPO", default="OWNER/REPO") }}/pull/{{ commit.remote.pr_number }})){% endif %}\
2325
{% endfor %}
2426
{% endfor %}
2527
"""

eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
55
import tseslint from 'typescript-eslint'
66

77
export default tseslint.config(
8-
{ ignores: ['dist', 'out'] },
8+
{ ignores: ['dist', 'out', '.auto-claude'] },
99
{
1010
extends: [js.configs.recommended, ...tseslint.configs.recommended],
1111
files: ['**/*.{ts,tsx}'],

package.json

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "termul-manager",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "Project-aware terminal that treats workspaces as first-class citizens",
55
"main": "./out/main/index.js",
66
"author": {
@@ -150,10 +150,22 @@
150150
],
151151
"win": {
152152
"target": [
153-
"nsis",
154-
"portable"
153+
{
154+
"target": "nsis",
155+
"arch": ["x64"]
156+
},
157+
{
158+
"target": "portable",
159+
"arch": ["x64"]
160+
}
155161
]
156162
},
163+
"nsis": {
164+
"artifactName": "Termul-Manager-Setup-${version}.${ext}"
165+
},
166+
"portable": {
167+
"artifactName": "Termul-Manager-${version}.${ext}"
168+
},
157169
"mac": {
158170
"target": [
159171
"dmg",

src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { registerSystemIpc } from './ipc/system.ipc'
99
import { registerClipboardIpc } from './ipc/clipboard.ipc'
1010
import { registerFilesystemIpc } from './ipc/filesystem.ipc'
1111
import { registerWindowIpc } from './ipc/window.ipc'
12+
import { registerVisibilityIpc } from './ipc/visibility.ipc'
1213
import { initRegisterUpdaterIpc, setUpdaterWindow } from './ipc/updater.ipc'
1314
import { flushPendingWrites } from './services/persistence-service'
1415
import { resetDefaultPtyManager } from './services/pty-manager'
@@ -110,6 +111,7 @@ export function initializeApp(): void {
110111
registerClipboardIpc() // Register clipboard IPC handlers
111112
registerFilesystemIpc() // Register filesystem IPC handlers
112113
initRegisterUpdaterIpc() // Register updater IPC handlers once
114+
registerVisibilityIpc() // Register visibility IPC handlers
113115

114116
// Load persisted window state and create window
115117
const windowState = await loadWindowState()

src/main/ipc/filesystem.ipc.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ import { IpcErrorCodes } from '../../shared/types/ipc.types'
1010
import type {
1111
DirectoryEntry,
1212
FileContent,
13-
FileInfo,
14-
ReadDirectoryOptions
13+
FileInfo
1514
} from '../../shared/types/filesystem.types'
1615

1716
let cleanupFileChangeListener: (() => void) | null = null
@@ -82,14 +81,13 @@ export function registerFilesystemIpc(): void {
8281
'filesystem:readDirectory',
8382
async (
8483
_event: IpcMainInvokeEvent,
85-
dirPath: string,
86-
options?: ReadDirectoryOptions
84+
dirPath: string
8785
): Promise<IpcResult<DirectoryEntry[]>> => {
8886
if (!isPathAllowed(dirPath)) {
8987
return createErrorResult('Path is outside allowed project directories', IpcErrorCodes.PATH_INVALID)
9088
}
9189
try {
92-
const entries = await service.readDirectory(dirPath, options)
90+
const entries = await service.readDirectory(dirPath)
9391
return createSuccessResult(entries)
9492
} catch (error) {
9593
return handleError(error)

src/main/ipc/visibility.ipc.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { ipcMain } from 'electron'
2+
import type { IpcResult } from '../../shared/types/ipc.types'
3+
4+
// Visibility state management for main process services
5+
let currentVisibilityState = true // Assume visible at start
6+
7+
/**
8+
* Get the current visibility state
9+
*/
10+
export function getVisibilityState(): boolean {
11+
return currentVisibilityState
12+
}
13+
14+
/**
15+
* Register IPC handlers for visibility state
16+
*/
17+
export function registerVisibilityIpc(): void {
18+
ipcMain.handle(
19+
'visibility:setState',
20+
async (_event, isVisible: boolean): Promise<IpcResult<void>> => {
21+
try {
22+
const previousState = currentVisibilityState
23+
currentVisibilityState = isVisible
24+
25+
// Log state change for debugging
26+
if (previousState !== isVisible) {
27+
console.log(`[Visibility] State changed: ${isVisible ? 'visible' : 'hidden'}`)
28+
}
29+
30+
return { success: true, data: undefined }
31+
} catch (error) {
32+
return {
33+
success: false,
34+
error: error instanceof Error ? error.message : 'Unknown error',
35+
code: 'VISIBILITY_ERROR'
36+
}
37+
}
38+
}
39+
)
40+
}

src/main/services/cwd-tracker.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,35 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
33
// We need to test the CwdTracker class directly without module reset
44
// Import the functions we need to test
55

6+
// Create a mock function for visibility state that can be controlled
7+
const mockGetVisibilityState = vi.fn(() => true)
8+
9+
// Mock the visibility IPC module before any imports
10+
vi.mock('../ipc/visibility.ipc', () => ({
11+
getVisibilityState: () => mockGetVisibilityState()
12+
}))
13+
614
describe('cwd-tracker', () => {
715
let cwdTrackerModule: typeof import('./cwd-tracker')
16+
let originalPlatform: PropertyDescriptor | undefined
817

918
beforeEach(async () => {
1019
vi.clearAllMocks()
20+
// Reset visibility mock to default (visible)
21+
mockGetVisibilityState.mockReturnValue(true)
22+
// Save original platform descriptor
23+
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
1124
// Import fresh module
1225
cwdTrackerModule = await import('./cwd-tracker')
1326
// Reset any existing tracker
1427
cwdTrackerModule.resetCwdTracker()
1528
})
1629

1730
afterEach(() => {
31+
// Restore original platform
32+
if (originalPlatform) {
33+
Object.defineProperty(process, 'platform', originalPlatform)
34+
}
1835
if (cwdTrackerModule) {
1936
cwdTrackerModule.resetCwdTracker()
2037
}
@@ -83,4 +100,112 @@ describe('cwd-tracker', () => {
83100
expect(cwd).toBeNull()
84101
})
85102
})
103+
104+
describe('visibility state handling', () => {
105+
it('should skip polling when app is not visible (non-Windows)', async () => {
106+
vi.useFakeTimers()
107+
108+
// Mock Unix platform
109+
Object.defineProperty(process, 'platform', {
110+
value: 'linux',
111+
writable: true,
112+
configurable: true
113+
})
114+
115+
// Mock visibility state as hidden
116+
mockGetVisibilityState.mockReturnValue(false)
117+
118+
// Reset and reimport to pick up platform change
119+
cwdTrackerModule.resetCwdTracker()
120+
const tracker = cwdTrackerModule.getDefaultCwdTracker()
121+
const callback = vi.fn()
122+
tracker.onCwdChanged(callback)
123+
124+
// Start tracking
125+
tracker.startTracking('term-1', 12345, '/home/user')
126+
127+
// Advance timers past the poll interval
128+
await vi.advanceTimersByTimeAsync(600)
129+
130+
// Callback should not be called since app is not visible
131+
expect(callback).not.toHaveBeenCalled()
132+
133+
// But getCwd should still return the initial CWD
134+
const cwd = await tracker.getCwd('term-1')
135+
expect(cwd).toBe('/home/user')
136+
137+
vi.useRealTimers()
138+
})
139+
140+
it('should poll when app is visible (non-Windows)', async () => {
141+
// Mock Unix platform
142+
Object.defineProperty(process, 'platform', {
143+
value: 'linux',
144+
writable: true,
145+
configurable: true
146+
})
147+
148+
// Mock visibility state as visible
149+
mockGetVisibilityState.mockReturnValue(true)
150+
151+
const tracker = cwdTrackerModule.getDefaultCwdTracker()
152+
153+
// Start tracking
154+
tracker.startTracking('term-1', 12345, '/home/user')
155+
156+
// The tracker should be tracking the terminal
157+
const cwd = await tracker.getCwd('term-1')
158+
expect(cwd).toBe('/home/user')
159+
})
160+
})
161+
162+
describe('Windows optimization', () => {
163+
it('should skip polling on Windows since CWD detection returns null', async () => {
164+
vi.useFakeTimers()
165+
166+
// Mock Windows platform
167+
Object.defineProperty(process, 'platform', {
168+
value: 'win32',
169+
writable: true,
170+
configurable: true
171+
})
172+
173+
const tracker = cwdTrackerModule.getDefaultCwdTracker()
174+
const callback = vi.fn()
175+
tracker.onCwdChanged(callback)
176+
177+
// Start tracking - on Windows, this should not start polling
178+
tracker.startTracking('term-1', 12345, 'C:\\Users\\test')
179+
180+
// Advance timers past the poll interval
181+
await vi.advanceTimersByTimeAsync(600)
182+
183+
// Callback should never be called since polling is skipped on Windows
184+
expect(callback).not.toHaveBeenCalled()
185+
186+
// But getCwd should still return the initial CWD
187+
const cwd = await tracker.getCwd('term-1')
188+
expect(cwd).toBe('C:\\Users\\test')
189+
190+
vi.useRealTimers()
191+
})
192+
193+
it('should start polling on non-Windows platforms', async () => {
194+
// Mock Unix platform
195+
Object.defineProperty(process, 'platform', {
196+
value: 'linux',
197+
writable: true,
198+
configurable: true
199+
})
200+
201+
const tracker = cwdTrackerModule.getDefaultCwdTracker()
202+
203+
// Start tracking - on non-Windows, this should start polling
204+
tracker.startTracking('term-1', 12345, '/home/user')
205+
206+
// The tracker should be tracking the terminal
207+
const cwd = await tracker.getCwd('term-1')
208+
expect(cwd).toBe('/home/user')
209+
})
210+
})
86211
})

src/main/services/cwd-tracker.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { promises as fs } from 'fs'
22
import * as path from 'path'
33
import { getDefaultPtyManager } from './pty-manager'
4+
import { getVisibilityState } from '../ipc/visibility.ipc'
45

56
export type CwdChangedCallback = (terminalId: string, cwd: string) => void
67

@@ -15,6 +16,8 @@ class CwdTracker {
1516
private callbacks: Set<CwdChangedCallback> = new Set()
1617
private pollInterval: NodeJS.Timeout | null = null
1718
private readonly POLL_INTERVAL_MS = 500
19+
// On Windows, CWD detection returns null, so skip polling to save CPU
20+
private readonly isWindows = process.platform === 'win32'
1821

1922
startTracking(terminalId: string, pid: number, initialCwd: string): void {
2023
this.trackedTerminals.set(terminalId, {
@@ -23,6 +26,11 @@ class CwdTracker {
2326
lastKnownCwd: initialCwd
2427
})
2528

29+
// Skip polling on Windows since CWD detection always returns null
30+
if (this.isWindows) {
31+
return
32+
}
33+
2634
if (!this.pollInterval && this.trackedTerminals.size > 0) {
2735
this.startPolling()
2836
}
@@ -65,6 +73,11 @@ class CwdTracker {
6573
}
6674

6775
private async pollAllTerminals(): Promise<void> {
76+
// Skip polling when app is not visible to save CPU
77+
if (!getVisibilityState()) {
78+
return
79+
}
80+
6881
const promises = Array.from(this.trackedTerminals.values()).map(async (state) => {
6982
try {
7083
const currentCwd = await this.detectCwd(state.pid)
@@ -91,7 +104,7 @@ class CwdTracker {
91104
}
92105

93106
private async detectCwd(pid: number): Promise<string | null> {
94-
if (process.platform === 'win32') {
107+
if (this.isWindows) {
95108
return this.detectCwdWindows(pid)
96109
} else {
97110
return this.detectCwdUnix(pid)

src/main/services/filesystem-service.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import type {
66
DirectoryEntry,
77
FileContent,
88
FileInfo,
9-
FileChangeEvent,
10-
ReadDirectoryOptions
9+
FileChangeEvent
1110
} from '../../shared/types/filesystem.types'
1211

1312
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1MB
@@ -62,8 +61,7 @@ export class FilesystemService {
6261
private changeCallbacks: FileChangeCallback[] = []
6362

6463
async readDirectory(
65-
dirPath: string,
66-
options?: ReadDirectoryOptions
64+
dirPath: string
6765
): Promise<DirectoryEntry[]> {
6866
const normalizedDir = normalize(dirPath)
6967
const entries = await readdir(normalizedDir, { withFileTypes: true })
@@ -82,9 +80,6 @@ export class FilesystemService {
8280
for (const entry of entries) {
8381
const name = entry.name
8482

85-
// Skip hidden files unless requested
86-
if (!options?.showHidden && name.startsWith('.')) continue
87-
8883
// Skip hardcoded ignores
8984
if (HARDCODED_IGNORES.has(name)) continue
9085

0 commit comments

Comments
 (0)