Skip to content
Open
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
11 changes: 6 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,13 @@ dev: node_modules/.installed build-main ## Start development server (Vite + node
# https://github.com/oven-sh/bun/issues/18275
@MUX_DISABLE_TELEMETRY=$(or $(MUX_DISABLE_TELEMETRY),1) NODE_OPTIONS="--max-old-space-size=4096" npm x concurrently -k --raw \
"bun x nodemon --watch src --watch tsconfig.main.json --watch tsconfig.json --ext ts,tsx,json --ignore dist --ignore node_modules --exec node scripts/build-main-watch.js" \
"npx esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --watch" \
"npx esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --external:@parcel/watcher --watch" \
"vite"
else
dev: node_modules/.installed build-main build-preload ## Start development server (Vite + tsgo watcher for 10x faster type checking)
@MUX_DISABLE_TELEMETRY=$(or $(MUX_DISABLE_TELEMETRY),1) bun x concurrently -k \
"bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
"bun x esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --watch" \
"bun x esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --external:@parcel/watcher --watch" \
"vite"
endif

Expand All @@ -153,7 +153,7 @@ dev-server: node_modules/.installed build-main ## Start server mode with hot rel
@# On Windows, use npm run because bunx doesn't correctly pass arguments
@MUX_DISABLE_TELEMETRY=$(or $(MUX_DISABLE_TELEMETRY),1) npmx concurrently -k \
"npmx nodemon --watch src --watch tsconfig.main.json --watch tsconfig.json --ext ts,tsx,json --ignore dist --ignore node_modules --exec node scripts/build-main-watch.js" \
"npx esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --watch" \
"npx esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --external:@parcel/watcher --watch" \
"npmx nodemon --watch dist/cli/index.js --watch dist/cli/server.js --delay 500ms --exec \"node dist/cli/index.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)\"" \
"$(SHELL) -lc \"MUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) MUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite\""
else
Expand All @@ -165,7 +165,7 @@ dev-server: node_modules/.installed build-main ## Start server mode with hot rel
@echo "For remote access: make dev-server VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0"
@MUX_DISABLE_TELEMETRY=$(or $(MUX_DISABLE_TELEMETRY),1) bun x concurrently -k \
"bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
"bun x esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --watch" \
"bun x esbuild src/cli/api.ts --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:@trpc/server --external:@parcel/watcher --watch" \
"bun x nodemon --watch dist/cli/index.js --watch dist/cli/server.js --delay 500ms --exec 'NODE_ENV=development node dist/cli/index.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)'" \
"MUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) MUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite"
endif
Expand Down Expand Up @@ -196,7 +196,8 @@ dist/cli/api.mjs: src/cli/api.ts src/cli/proxifyOrpc.ts $(TS_SOURCES)
--outfile=dist/cli/api.mjs \
--external:zod \
--external:commander \
--external:@trpc/server
--external:@trpc/server \
--external:@parcel/watcher

build-preload: node_modules/.installed dist/preload.js ## Build preload script

Expand Down
92 changes: 66 additions & 26 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@orpc/client": "^1.11.3",
"@orpc/openapi": "^1.12.2",
"@orpc/server": "^1.11.3",
"@parcel/watcher": "^2.5.1",
"@orpc/zod": "^1.11.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
Expand Down Expand Up @@ -235,7 +236,8 @@
"asarUnpack": [
"dist/**/*.wasm",
"dist/**/*.map",
"**/node_modules/node-pty/build/**/*"
"**/node_modules/node-pty/build/**/*",
"**/node_modules/@parcel/watcher*/**/*"
],
"mac": {
"category": "public.app-category.developer-tools",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface ReviewControlsProps {
isLoading?: boolean;
workspaceId: string;
workspacePath: string;
/** If set, show an auto-refresh countdown (e.g., for origin/* bases). */
autoRefreshSecondsRemaining?: number | null;
refreshTrigger?: number;
}

Expand All @@ -27,6 +29,7 @@ export const ReviewControls: React.FC<ReviewControlsProps> = ({
isLoading = false,
workspaceId,
workspacePath,
autoRefreshSecondsRemaining,
refreshTrigger,
}) => {
// Local state for input value - only commit on blur/Enter
Expand Down Expand Up @@ -83,6 +86,11 @@ export const ReviewControls: React.FC<ReviewControlsProps> = ({

return (
<div className="bg-separator border-border-light flex flex-wrap items-center gap-3 border-b px-3 py-2 text-[11px]">
{autoRefreshSecondsRemaining != null && (
<span className="text-muted whitespace-nowrap">
Auto-refresh in: {autoRefreshSecondsRemaining}s
</span>
)}
{onRefresh && <RefreshButton onClick={onRefresh} isLoading={isLoading} />}
<label className="text-muted font-medium whitespace-nowrap">Base:</label>
<input
Expand Down
191 changes: 189 additions & 2 deletions src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ function makeReviewPanelCacheKey(params: {
}

type ExecuteBashResult = Awaited<ReturnType<APIClient["workspace"]["executeBash"]>>;

const ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS = 30_000;

function getOriginBranchForFetch(diffBase: string): string | null {
const trimmed = diffBase.trim();
if (!trimmed.startsWith("origin/")) return null;

const branch = trimmed.slice("origin/".length);

// Avoid shell injection; diffBase is user-controlled.
if (!/^[0-9A-Za-z._/-]+$/.test(branch)) return null;

return branch;
}

function shouldWatchReviewDiffBase(diffBase: string): boolean {
// Base is considered local as long as it isn't a remote tracking ref.
// Explicitly match the user requirement: skip when it starts with "origin/".
return !diffBase.trim().startsWith("origin/");
}

type ExecuteBashSuccess = Extract<ExecuteBashResult, { success: true }>;

async function executeWorkspaceBashAndCache<T extends ReviewPanelCacheValue>(params: {
Expand Down Expand Up @@ -219,13 +240,178 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
[diffState]
);

const [originAutoRefreshSecondsRemaining, setOriginAutoRefreshSecondsRemaining] = useState<
number | null
>(null);
const originAutoRefreshDeadlineRef = useRef<number | null>(null);
const originAutoRefreshInFlightRef = useRef(false);
const [filters, setFilters] = useState<ReviewFiltersType>({
showReadHunks: showReadHunks,
diffBase: diffBase,
includeUncommitted: includeUncommitted,
});

// Auto-refresh diffs for local bases by watching the filesystem.
//
// This is intentionally scoped:
// - Only runs when the base is local (not starting with "origin/")
// - Uses @parcel/watcher (native backends) for low-latency refresh
useEffect(() => {
if (!api || isCreating) return;
if (!shouldWatchReviewDiffBase(filters.diffBase)) return;

const abortController = new AbortController();
const signal = abortController.signal;

let debounceTimer: NodeJS.Timeout | null = null;

(async () => {
try {
const iterator = await api.workspace.onFileChanges({ workspaceId }, { signal });
for await (const _event of iterator) {
if (signal.aborted) break;

if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
setRefreshTrigger((prev) => prev + 1);
}, 75);
}
// Stream ended (watcher stopped, workspace changed, etc.) - refresh to catch any final state
if (!signal.aborted) {
setRefreshTrigger((prev) => prev + 1);
}
} catch {
// Cancelled via abort signal (expected on cleanup)
}
})();

return () => {
abortController.abort();
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [api, workspaceId, filters.diffBase, isCreating]);

// Auto-refresh remote origin/* bases every 30s (with a user-visible countdown).
useEffect(() => {
if (!api || isCreating) {
originAutoRefreshDeadlineRef.current = null;
originAutoRefreshInFlightRef.current = false;
setOriginAutoRefreshSecondsRemaining(null);
return;
}

const originBranch = getOriginBranchForFetch(filters.diffBase);
if (!originBranch) {
originAutoRefreshDeadlineRef.current = null;
originAutoRefreshInFlightRef.current = false;
setOriginAutoRefreshSecondsRemaining(null);
return;
}

originAutoRefreshDeadlineRef.current = Date.now() + ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS;

const resetCountdown = () => {
originAutoRefreshDeadlineRef.current = Date.now() + ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS;
setOriginAutoRefreshSecondsRemaining(
Math.ceil(ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS / 1000)
);
};

resetCountdown();

let lastRenderedSeconds: number | null = null;

const interval = setInterval(() => {
const deadline = originAutoRefreshDeadlineRef.current;
if (!deadline) return;

const secondsRemaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
if (secondsRemaining !== lastRenderedSeconds) {
lastRenderedSeconds = secondsRemaining;
setOriginAutoRefreshSecondsRemaining(secondsRemaining);
}

if (secondsRemaining > 0) return;
if (originAutoRefreshInFlightRef.current) return;

originAutoRefreshInFlightRef.current = true;

// Reset early so we don't immediately re-fire if fetch takes time.
resetCountdown();

api.workspace
.executeBash({
workspaceId,
script: `git fetch origin ${originBranch} --quiet || true`,
options: {
timeout_secs: 30,
},
})
.catch((err) => {
console.debug("ReviewPanel origin fetch failed", err);
})
.finally(() => {
originAutoRefreshInFlightRef.current = false;
setRefreshTrigger((prev) => prev + 1);
});
}, 250);

return () => {
clearInterval(interval);
originAutoRefreshDeadlineRef.current = null;
originAutoRefreshInFlightRef.current = false;
setOriginAutoRefreshSecondsRemaining(null);
};
}, [api, workspaceId, filters.diffBase, isCreating]);

// Focus panel when focusTrigger changes (preserves current hunk selection)

const handleRefreshRef = useRef<() => void>(() => {
console.debug("ReviewPanel handleRefreshRef called before init");
});
handleRefreshRef.current = () => {
if (!api || isCreating) return;

const originBranch = getOriginBranchForFetch(filters.diffBase);
if (originBranch) {
// Reset countdown on manual refresh so the user doesn't see an immediate auto-refresh.
originAutoRefreshDeadlineRef.current = Date.now() + ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS;
setOriginAutoRefreshSecondsRemaining(
Math.ceil(ORIGIN_REVIEW_AUTO_REFRESH_INTERVAL_MS / 1000)
);

if (originAutoRefreshInFlightRef.current) {
setRefreshTrigger((prev) => prev + 1);
return;
}

originAutoRefreshInFlightRef.current = true;

api.workspace
.executeBash({
workspaceId,
script: `git fetch origin ${originBranch} --quiet || true`,
options: {
timeout_secs: 30,
},
})
.catch((err) => {
console.debug("ReviewPanel origin fetch failed", err);
})
.finally(() => {
originAutoRefreshInFlightRef.current = false;
setRefreshTrigger((prev) => prev + 1);
});

return;
}

setRefreshTrigger((prev) => prev + 1);
};

const handleRefresh = () => {
handleRefreshRef.current();
};
useEffect(() => {
if (focusTrigger && focusTrigger > 0) {
panelRef.current?.focus();
Expand Down Expand Up @@ -730,7 +916,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
const handleKeyDown = (e: KeyboardEvent) => {
if (matchesKeybind(e, KEYBINDS.REFRESH_REVIEW)) {
e.preventDefault();
setRefreshTrigger((prev) => prev + 1);
handleRefreshRef.current();
} else if (matchesKeybind(e, KEYBINDS.FOCUS_REVIEW_SEARCH)) {
e.preventDefault();
searchInputRef.current?.focus();
Expand Down Expand Up @@ -765,7 +951,8 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
filters={filters}
stats={stats}
onFiltersChange={setFilters}
onRefresh={() => setRefreshTrigger((prev) => prev + 1)}
onRefresh={handleRefresh}
autoRefreshSecondsRemaining={originAutoRefreshSecondsRemaining}
isLoading={
diffState.status === "loading" || diffState.status === "refreshing" || isLoadingTree
}
Expand Down
8 changes: 8 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ export const workspace = {
})
),
},
onFileChanges: {
input: z.object({ workspaceId: z.string() }),
output: eventIterator(
z.object({
paths: z.array(z.string()),
})
),
},
activity: {
list: {
input: z.void(),
Expand Down
26 changes: 26 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";

import { createRuntime } from "@/node/runtime/runtimeFactory";
import { readPlanFile } from "@/node/utils/runtime/helpers";
import { isSSHRuntime } from "@/common/types/runtime";
import { workspaceFileWatcherService } from "@/node/services/workspaceFileWatcherService";
import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator";

export const router = (authToken?: string) => {
Expand Down Expand Up @@ -543,6 +545,30 @@ export const router = (authToken?: string) => {
service.off("metadata", onMetadata);
}
}),
onFileChanges: t
.input(schemas.workspace.onFileChanges.input)
.output(schemas.workspace.onFileChanges.output)
.handler(async function* ({ context, input }) {
const metadata = await context.workspaceService.getInfo(input.workspaceId);
if (!metadata) return;
if (isSSHRuntime(metadata.runtimeConfig)) return;

const { push, iterate, end } = createAsyncEventQueue<{ paths: string[] }>();

const unsubscribe = await workspaceFileWatcherService.watchWorkspace(
metadata.namedWorkspacePath,
(paths) => {
push({ paths });
}
);

try {
yield* iterate();
} finally {
end();
unsubscribe();
}
}),
activity: {
list: t
.input(schemas.workspace.activity.list.input)
Expand Down
Loading