Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.1] - 2026-06-06

### Fixed

- 🛑 **File browser no longer refreshes constantly.** Replaced the macOS `PollingObserver` (which generated a feedback loop of phantom filesystem events) with the native `FSEventsObserver`. Background refreshes are now silent — no more loading spinner flash on every update.
- 🔇 **Eliminated noisy filesystem watcher events.** The watcher now ignores changes in `.git`, `__pycache__`, `.DS_Store`, and `node_modules` directories, which were triggering unnecessary file browser refreshes.
- ⚡ **Centralized git status store.** All components (GitBar, GitView, FileEditor, layout) now share a single `gitStatusStore` instead of each independently polling `git/status`. On page load, this reduces git status API calls from ~6+ down to 1.
- 🔁 **Reduced git polling frequency.** Removed the 5-second git status polling intervals that were running in multiple components simultaneously.

## [0.1.0] - 2026-06-06

### Added
Expand Down
32 changes: 30 additions & 2 deletions cptr/frontend/src/lib/apis/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,36 @@
*/
import { fetchJSON, jsonBody } from '$lib/apis';

export const getGitStatus = (root: string) =>
fetchJSON(`/api/git/status?root=${encodeURIComponent(root)}`);
// Deduplicate concurrent getGitStatus calls.
// Multiple components (layout, GitBar, FileEditor × N) all fetch on mount;
// this ensures they share a single in-flight request and a brief result cache.
const _statusCache = new Map<string, { promise: Promise<unknown>; ts: number }>();
const STATUS_CACHE_MS = 2000;

export const getGitStatus = (root: string): Promise<unknown> => {
const cached = _statusCache.get(root);
if (cached && Date.now() - cached.ts < STATUS_CACHE_MS) {
return cached.promise;
}
const promise = fetchJSON(`/api/git/status?root=${encodeURIComponent(root)}`);
_statusCache.set(root, { promise, ts: Date.now() });
// Clean up after cache window
promise.finally(() => {
setTimeout(() => {
const entry = _statusCache.get(root);
if (entry && entry.promise === promise) {
_statusCache.delete(root);
}
}, STATUS_CACHE_MS);
});
return promise;
};

/** Force a fresh git status fetch, bypassing the cache. */
export const getGitStatusFresh = (root: string): Promise<unknown> => {
_statusCache.delete(root);
return getGitStatus(root);
};

export const getGitLog = (root: string, limit = 30) =>
fetchJSON(`/api/git/log?root=${encodeURIComponent(root)}&limit=${limit}`);
Expand Down
19 changes: 13 additions & 6 deletions cptr/frontend/src/lib/components/FileBrowser.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

let entries = $state<FileEntry[]>([]);
let loading = $state(false);
let initialLoad = $state(true);
let fetchTimer: ReturnType<typeof setTimeout> | null = null;
let fetching = false;
let error = $state<string | null>(null);
Expand Down Expand Up @@ -109,6 +110,7 @@
// Navigated to a new directory: reset tree state
expandedDirs = new Set();
dirContents = new Map();
initialLoad = true;
} else {
// Component (re)mount: restore tree state from cache
const cachedExpanded = _treeExpandedCache.get(cwd);
Expand All @@ -130,11 +132,12 @@
}
});

// Auto-refresh on filesystem changes (debounced)
// Auto-refresh on filesystem changes (debounced, silent)
$effect(() => {
const _tick = systemEvents.fsTick; // subscribe
if (_tick > 0 && cwd && !fetching && systemEvents.isRelevantFsChange(cwd)) {
debouncedFetch(cwd);
// Use a longer debounce for auto-refresh to batch rapid fs events
debouncedFetch(cwd, 800);
// Also refresh expanded directories
for (const dir of expandedDirs) {
fetchSubdir(dir);
Expand All @@ -153,20 +156,23 @@
}
}

function debouncedFetch(path: string) {
function debouncedFetch(path: string, delay = 300) {
if (fetchTimer) clearTimeout(fetchTimer);
fetchTimer = setTimeout(() => {
fetchTimer = null;
fetchDirectoryImmediate(path);
}, 300);
}, delay);
}

async function fetchDirectoryImmediate(path: string) {
if (fetchTimer) clearTimeout(fetchTimer);
fetchTimer = null;
if (fetching) return; // skip overlapping fetches
fetching = true;
loading = true;
// Only show the loading spinner on initial load (no entries yet).
// Background refreshes silently update entries in-place.
const isInitial = entries.length === 0;
if (isInitial) loading = true;
error = null;
try {
const data = await listDir(path);
Expand All @@ -183,6 +189,7 @@
}
} finally {
loading = false;
initialLoad = false;
fetching = false;
}
}
Expand Down Expand Up @@ -752,7 +759,7 @@
</div>
{/if}

{#if loading}
{#if loading && initialLoad}
<div class="flex items-center justify-center py-12">
<div
class="w-4 h-4 border-2 border-gray-300 border-t-gray-600 dark:border-gray-700 dark:border-t-gray-400 rounded-full animate-spin"
Expand Down
28 changes: 11 additions & 17 deletions cptr/frontend/src/lib/components/FileEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import { get } from 'svelte/store';
import { tooltip } from '$lib/tooltip';
import { readFile, writeFile } from '$lib/apis/files';
import { getGitDiff, getGitStatus } from '$lib/apis/git';
import { getGitDiff } from '$lib/apis/git';
import { gitStatusStore } from '$lib/stores/gitStatus.svelte';
import Icon from './Icon.svelte';
import SaveDialog from './SaveDialog.svelte';
import type RichTextEditorType from './markdown/RichTextEditor.svelte';
Expand Down Expand Up @@ -356,23 +357,16 @@
loading = false;
}

// Check git status for this file (non-blocking)
// Check git status for this file from centralized store (non-blocking)
if (!isUntitled && !isBinaryPreview) {
try {
const ws = get(activeWorkspace);
if (ws) {
const status = (await getGitStatus(ws.path)) as {
is_repo?: boolean;
files?: { path: string }[];
};
if (status?.is_repo && status.files) {
const relPath = path.startsWith(ws.path)
? path.slice(ws.path.replace(/\/$/, '').length + 1)
: path;
hasGitChanges = status.files.some((f) => f.path === relPath);
}
}
} catch {}
const status = gitStatusStore.status;
const ws = get(activeWorkspace);
if (ws && status?.is_repo && status.files) {
const relPath = path.startsWith(ws.path)
? path.slice(ws.path.replace(/\/$/, '').length + 1)
: path;
hasGitChanges = status.files.some((f) => f.path === relPath);
}
}
}

Expand Down
53 changes: 29 additions & 24 deletions cptr/frontend/src/lib/components/GitBar.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script lang="ts">
import { activeWorkspace, openFileTab } from '$lib/stores';
import {
getGitStatus,
getGitLog,
getGitDiff,
getGitShow,
Expand All @@ -15,6 +14,7 @@
checkoutBranch,
createGitBranch
} from '$lib/apis/git';
import { gitStatusStore } from '$lib/stores/gitStatus.svelte';
import Icon from './Icon.svelte';
import DropdownMenu from './DropdownMenu.svelte';
import { tooltip } from '$lib/tooltip';
Expand All @@ -29,13 +29,6 @@
let view = $state<'changes' | 'history'>('changes');
let showDiff = $state(false);
let showBranches = $state(false);
let gitStatus = $state<{
is_repo: boolean;
branch: string;
ahead: number;
behind: number;
files: GitFile[];
} | null>(null);
let commits = $state<Commit[]>([]);
let branchData = $state<{ current: string; local: string[]; remote: string[] } | null>(null);
let newBranchName = $state('');
Expand All @@ -60,15 +53,14 @@
const allStaged = $derived(totalChanges > 0 && unstagedFiles.length === 0);
const someStaged = $derived(stagedFiles.length > 0 && unstagedFiles.length > 0);

$effect(() => {
if (workspacePath) {
refresh();
const iv = setInterval(refresh, 5000);
return () => clearInterval(iv);
} else {
gitStatus = null;
}
});
// Read from centralized store instead of independent polling
let gitStatus = $derived(gitStatusStore.status as {
is_repo: boolean;
branch: string;
ahead: number;
behind: number;
files: GitFile[];
} | null);

// Clear stale selection when file is no longer in the changed list
$effect(() => {
Expand Down Expand Up @@ -97,12 +89,7 @@
});

async function refresh() {
if (!workspacePath) return;
try {
gitStatus = await getGitStatus(workspacePath);
} catch {
/* silent */
}
await gitStatusStore.refresh();
}

async function selectFile(path: string, staged: boolean, untracked: boolean = false) {
Expand Down Expand Up @@ -388,8 +375,23 @@
return '';
}
}

$effect(() => {
if (!expanded) return;
function onKeydown(e: KeyboardEvent) {
if (!gitStatus?.is_repo) return;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p' && !e.shiftKey && !e.altKey) {
e.preventDefault();
doPush();
}
}
window.addEventListener('keydown', onKeydown);
return () => window.removeEventListener('keydown', onKeydown);
});
</script>



{#if gitStatus?.is_repo}
<div
class="shrink-0 border-t border-gray-200 dark:border-white/6 relative"
Expand All @@ -407,7 +409,10 @@
class="flex items-center h-7 px-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-white/3 transition-colors duration-75"
onclick={() => {
expanded = !expanded;
if (expanded && view === 'history') loadHistory();
if (expanded) {
refresh();
if (view === 'history') loadHistory();
}
}}
>
<!-- Branch button (opens branch picker, stops expand) -->
Expand Down
45 changes: 19 additions & 26 deletions cptr/frontend/src/lib/components/GitView.svelte
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
<script lang="ts">
import { activeWorkspace, openFileTab, gitReviewOpen } from '$lib/stores';
import { getGitDiff, getGitStatus } from '$lib/apis/git';
import { getGitDiff } from '$lib/apis/git';
import { gitStatusStore, type GitStatus, type GitFile } from '$lib/stores/gitStatus.svelte';

import { tooltip } from '$lib/tooltip';
import Icon from './Icon.svelte';

type GitFile = { path: string; status: string; staged: boolean };
type GitStatus = {
is_repo: boolean;
branch: string;
upstream?: string;
ahead: number;
behind: number;
files: GitFile[];
};

type DiffLine = { type: 'added' | 'removed' | 'context'; content: string };
type DiffHunk = { header: string; lines: DiffLine[] };
type DiffFile = { path: string; hunks: DiffHunk[] };
Expand All @@ -29,7 +22,7 @@
};
type NumberedLine = DiffLine & { oldNumber: number | null; newNumber: number | null };

let gitStatus = $state<GitStatus | null>(null);
let gitStatus = $derived(gitStatusStore.status);
let reviewFiles = $state<ReviewFile[]>([]);
let loading = $state(false);
let refreshing = $state(false);
Expand All @@ -40,22 +33,23 @@
const totalChanges = $derived(reviewFiles.length);
const anyExpanded = $derived(reviewFiles.some((file) => file.expanded));

// React to git status changes from centralized store
let _prevStatusRef: GitStatus | null = null;
$effect(() => {
if (!workspacePath) {
gitStatus = null;
const status = gitStatus;
if (!workspacePath || !status) {
reviewFiles = [];
return;
}

refreshReview(true);
const interval = setInterval(() => refreshReview(false), 5000);
return () => {
loadSeq += 1;
clearInterval(interval);
};
// Only re-fetch diffs when status actually changes
if (status !== _prevStatusRef) {
const isInitial = _prevStatusRef === null;
_prevStatusRef = status;
fetchDiffs(status, isInitial);
}
});

async function refreshReview(initial = false) {
async function fetchDiffs(status: GitStatus, initial = false) {
const seq = ++loadSeq;
const root = workspacePath;
if (!root) return;
Expand All @@ -65,10 +59,6 @@
error = '';

try {
const status = (await getGitStatus(root)) as GitStatus;
if (seq !== loadSeq || root !== workspacePath) return;

gitStatus = status;
if (!status.is_repo) {
reviewFiles = [];
return;
Expand Down Expand Up @@ -112,7 +102,6 @@
} catch (e) {
if (seq !== loadSeq) return;
error = e instanceof Error ? e.message : 'Failed to load git changes';
gitStatus = null;
reviewFiles = [];
} finally {
if (seq === loadSeq) {
Expand All @@ -122,6 +111,10 @@
}
}

async function refreshReview(initial = false) {
await gitStatusStore.refresh();
}

function fileKey(file: GitFile): string {
return `${file.staged ? 'staged' : 'unstaged'}:${file.status}:${file.path}`;
}
Expand Down
4 changes: 2 additions & 2 deletions cptr/frontend/src/lib/components/chat/ChatPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -799,8 +799,8 @@
<div class="flex flex-col h-full bg-white dark:bg-black">
{#if isLanding}
<!-- Landing: input + recent chats -->
<div class="flex-1 overflow-y-auto flex items-center justify-center">
<div class="max-w-xl w-full mx-auto px-4 flex flex-col pb-[5vh]">
<div class="flex-1 overflow-y-auto flex flex-col">
<div class="max-w-xl w-full mx-auto px-4 flex flex-col my-auto py-6">
<!-- Greeting -->
<div class="mb-8 text-center">
<h1 class="text-lg font-normal text-gray-800 dark:text-gray-200 tracking-tight">
Expand Down
Loading
Loading