mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(desktop): manual window dragging and agent tab pruning
This commit is contained in:
@@ -26,12 +26,12 @@
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/expo-two-way-audio": "0.1.30",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.30",
|
||||
"@getpaseo/server": "0.1.30",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
@@ -58,7 +58,7 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useGlobalSearchParams, useLocalSearchParams } from 'expo-router'
|
||||
import { useCallback } from 'react'
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from '@/utils/host-routes'
|
||||
|
||||
export default function HostWorkspaceLayout() {
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[]
|
||||
workspaceId?: string | string[]
|
||||
@@ -22,12 +25,20 @@ export default function HostWorkspaceLayout() {
|
||||
const openValue = Array.isArray(globalParams.open) ? globalParams.open[0] : globalParams.open
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue)
|
||||
|
||||
const handleOpenIntentConsumed = useCallback(
|
||||
function handleOpenIntentConsumed() {
|
||||
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any)
|
||||
},
|
||||
[router, serverId, workspaceId]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
openIntent={openIntent}
|
||||
onOpenIntentConsumed={handleOpenIntentConsumed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ScreenHeader({
|
||||
? trafficLightPadding.left
|
||||
: 0;
|
||||
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
@@ -50,7 +50,6 @@ export function ScreenHeader({
|
||||
style={[
|
||||
styles.row,
|
||||
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
|
||||
dragRegionStyle,
|
||||
]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
|
||||
@@ -602,7 +602,7 @@ function DesktopSidebar({
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
|
||||
const dragHandlers = useDesktopDragHandlers()
|
||||
const trafficLightPadding = useTrafficLightPadding()
|
||||
const hostStatusDotStyle = useMemo(
|
||||
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
||||
@@ -616,9 +616,9 @@ function DesktopSidebar({
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
{trafficLightPadding.top > 0 ? (
|
||||
<View style={[{ height: trafficLightPadding.top }, dragRegionStyle]} {...dragHandlers} />
|
||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
||||
) : null}
|
||||
<View style={[styles.sidebarHeader, dragRegionStyle]} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
|
||||
@@ -12,14 +12,6 @@ export function getDesktopWindow(): DesktopWindowBridge | null {
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDesktopDragging(): Promise<void> {
|
||||
const win = getDesktopWindow();
|
||||
if (!win || typeof win.startDragging !== "function") {
|
||||
return;
|
||||
}
|
||||
await win.startDragging();
|
||||
}
|
||||
|
||||
export async function toggleDesktopMaximize(): Promise<void> {
|
||||
const win = getDesktopWindow();
|
||||
if (!win || typeof win.toggleMaximize !== "function") {
|
||||
|
||||
@@ -41,7 +41,9 @@ export interface DesktopOpenerBridge {
|
||||
|
||||
export interface DesktopWindowBridge {
|
||||
label?: string;
|
||||
startDragging?: () => Promise<void>;
|
||||
startMove?: (screenX: number, screenY: number) => void;
|
||||
moving?: (screenX: number, screenY: number) => void;
|
||||
endMove?: () => void;
|
||||
toggleMaximize?: () => Promise<void>;
|
||||
isFullscreen?: () => Promise<boolean>;
|
||||
onResized?: <TEvent = unknown>(
|
||||
|
||||
@@ -238,7 +238,7 @@ function DraftAgentScreenContent({
|
||||
const activateExplorerTabForCheckout = usePanelStore(
|
||||
(state) => state.activateExplorerTabForCheckout
|
||||
)
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
|
||||
const dragHandlers = useDesktopDragHandlers()
|
||||
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
|
||||
const draftIdRef = useRef(generateDraftId())
|
||||
const draftAgentIdRef = useRef(generateDraftId())
|
||||
@@ -973,7 +973,7 @@ function DraftAgentScreenContent({
|
||||
const explorerServerId = draftExplorerCheckout?.serverId ?? null
|
||||
const explorerIsGit = draftExplorerCheckout?.isGit ?? false
|
||||
const mainContent = (
|
||||
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={styles.outerContainer}>
|
||||
<View style={styles.agentPanel}>
|
||||
<View
|
||||
|
||||
@@ -21,7 +21,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
|
||||
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
@@ -30,7 +30,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
}, [isMobile, openAgentList]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
|
||||
<SidebarMenuToggle />
|
||||
</View>
|
||||
|
||||
@@ -18,10 +18,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
export function StartupSplashScreen() {
|
||||
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.status}>Starting up…</Text>
|
||||
</View>
|
||||
|
||||
@@ -101,14 +101,30 @@ describe("workspace agent visibility", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prune archived agent tabs when knownAgentIds contains the agent", () => {
|
||||
it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => {
|
||||
const knownAgentIds = new Set(["archived-agent"]);
|
||||
const activeAgentIds = new Set<string>();
|
||||
|
||||
expect(
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: "archived-agent",
|
||||
agentsHydrated: true,
|
||||
knownAgentIds,
|
||||
activeAgentIds,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prune active agent tabs", () => {
|
||||
const knownAgentIds = new Set(["active-agent"]);
|
||||
const activeAgentIds = new Set(["active-agent"]);
|
||||
|
||||
expect(
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: "active-agent",
|
||||
agentsHydrated: true,
|
||||
knownAgentIds,
|
||||
activeAgentIds,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -119,6 +135,7 @@ describe("workspace agent visibility", () => {
|
||||
agentId: "missing-agent",
|
||||
agentsHydrated: true,
|
||||
knownAgentIds: new Set<string>(),
|
||||
activeAgentIds: new Set<string>(),
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -71,10 +71,15 @@ export function canOpenAgentTabFromRoute(input: {
|
||||
return input.knownAgentIds.has(input.agentId);
|
||||
}
|
||||
|
||||
// Prune agent tabs that are unknown (deleted) or archived.
|
||||
// Archived agents get pruned so that archiving on one client closes the tab on all clients.
|
||||
// Users can still explicitly open archived agents via navigation (openIntent),
|
||||
// which re-creates the tab in a separate effect.
|
||||
export function shouldPruneWorkspaceAgentTab(input: {
|
||||
agentId: string;
|
||||
agentsHydrated: boolean;
|
||||
knownAgentIds: Set<string>;
|
||||
activeAgentIds: Set<string>;
|
||||
}): boolean {
|
||||
if (!input.agentId.trim()) {
|
||||
return false;
|
||||
@@ -82,5 +87,5 @@ export function shouldPruneWorkspaceAgentTab(input: {
|
||||
if (!input.agentsHydrated) {
|
||||
return false;
|
||||
}
|
||||
return !input.knownAgentIds.has(input.agentId);
|
||||
return !input.activeAgentIds.has(input.agentId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Platform, type ViewStyle } from "react-native";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
|
||||
import {
|
||||
getIsDesktopMac,
|
||||
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
@@ -9,25 +8,6 @@ import {
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
|
||||
type DesktopDragHandlers = {
|
||||
style?: CSSProperties & ViewStyle;
|
||||
};
|
||||
|
||||
const DESKTOP_DRAG_REGION_STYLE = {
|
||||
WebkitAppRegion: "drag",
|
||||
} as CSSProperties & ViewStyle;
|
||||
|
||||
export async function startDragging() {
|
||||
const win = getDesktopWindow();
|
||||
if (win && typeof win.startDragging === "function") {
|
||||
try {
|
||||
await win.startDragging();
|
||||
} catch (error) {
|
||||
console.warn("[DesktopWindow] startDragging failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMaximize() {
|
||||
const win = getDesktopWindow();
|
||||
if (win && typeof win.toggleMaximize === "function") {
|
||||
@@ -39,14 +19,90 @@ export async function toggleMaximize() {
|
||||
}
|
||||
}
|
||||
|
||||
export function useDesktopDragHandlers(): DesktopDragHandlers {
|
||||
if (Platform.OS !== "web" || !isDesktop()) {
|
||||
return {};
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual window dragging via pointer events.
|
||||
// Mirrors the Tauri implementation: single pointerdown handler with
|
||||
// double-click-to-maximize via timing, closest() for interactive check,
|
||||
// and pointer capture for move tracking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
style: DESKTOP_DRAG_REGION_STYLE,
|
||||
};
|
||||
const INTERACTIVE_SELECTOR =
|
||||
"button, a, input, textarea, select, " +
|
||||
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
|
||||
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
|
||||
"[role='menuitem'], [contenteditable='true']";
|
||||
|
||||
const DOUBLE_CLICK_MS = 300;
|
||||
|
||||
type DesktopDragViewProps = Pick<
|
||||
ViewProps,
|
||||
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
|
||||
>;
|
||||
|
||||
export function useDesktopDragHandlers(): DesktopDragViewProps {
|
||||
const isDragging = useRef(false);
|
||||
const lastPointerDownAt = useRef(0);
|
||||
const isActive = Platform.OS === "web" && isDesktop();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
function handleBlur() {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
getDesktopWindow()?.endMove?.();
|
||||
}
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => window.removeEventListener("blur", handleBlur);
|
||||
}, [isActive]);
|
||||
|
||||
return useMemo((): DesktopDragViewProps => {
|
||||
if (!isActive) return {};
|
||||
|
||||
function stopDrag(e: RNPointerEvent) {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
// On web, currentTarget is a DOM Element (typed as HostInstance in RN)
|
||||
const el = e.currentTarget as unknown as Element | null;
|
||||
if (el && "releasePointerCapture" in el) {
|
||||
el.releasePointerCapture(e.nativeEvent.pointerId);
|
||||
}
|
||||
getDesktopWindow()?.endMove?.();
|
||||
}
|
||||
|
||||
return {
|
||||
onPointerDown: (e: RNPointerEvent) => {
|
||||
if (e.nativeEvent.button !== 0) return;
|
||||
|
||||
// On web, e.target is a DOM Element (typed as HostInstance in RN)
|
||||
const target = e.target as unknown as Element;
|
||||
if (target.closest?.(INTERACTIVE_SELECTOR)) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
|
||||
lastPointerDownAt.current = 0;
|
||||
void toggleMaximize();
|
||||
return;
|
||||
}
|
||||
lastPointerDownAt.current = now;
|
||||
|
||||
const win = getDesktopWindow();
|
||||
if (!win?.startMove) return;
|
||||
|
||||
isDragging.current = true;
|
||||
const el = e.currentTarget as unknown as Element;
|
||||
el.setPointerCapture(e.nativeEvent.pointerId);
|
||||
win.startMove(e.nativeEvent.screenX, e.nativeEvent.screenY);
|
||||
},
|
||||
onPointerMove: (e: RNPointerEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
getDesktopWindow()?.moving?.(e.nativeEvent.screenX, e.nativeEvent.screenY);
|
||||
},
|
||||
onPointerUp: stopDrag,
|
||||
onPointerCancel: stopDrag,
|
||||
};
|
||||
}, [isActive]);
|
||||
}
|
||||
|
||||
export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
|
||||
@@ -71,7 +71,7 @@ async function createMainWindow(): Promise<void> {
|
||||
show: false,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
titleBarStyle: isMac ? "hidden" : "default",
|
||||
trafficLightPosition: isMac ? { x: 16, y: 20 } : undefined,
|
||||
trafficLightPosition: isMac ? { x: 16, y: 17 } : undefined,
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -19,7 +19,11 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
},
|
||||
window: {
|
||||
getCurrentWindow: () => ({
|
||||
startDragging: () => ipcRenderer.invoke("paseo:window:startDragging"),
|
||||
startMove: (screenX: number, screenY: number) =>
|
||||
ipcRenderer.send("paseo:window:startMove", { screenX, screenY }),
|
||||
moving: (screenX: number, screenY: number) =>
|
||||
ipcRenderer.send("paseo:window:moving", { screenX, screenY }),
|
||||
endMove: () => ipcRenderer.send("paseo:window:endMove"),
|
||||
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
||||
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
||||
onResized: (handler: EventHandler): (() => void) => {
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
export function registerWindowManager(): void {
|
||||
ipcMain.handle("paseo:window:startDragging", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
// Desktop dragging is handled via CSS `-webkit-app-region: drag`.
|
||||
// This handler exists only to satisfy the renderer bridge contract.
|
||||
if (win) {
|
||||
// No-op.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
|
||||
// State is keyed per window id to support multiple windows.
|
||||
// ---------------------------------------------------------------------------
|
||||
const moveStates = new Map<number, { offsetX: number; offsetY: number }>();
|
||||
|
||||
ipcMain.on(
|
||||
"paseo:window:startMove",
|
||||
(event, payload: { screenX: number; screenY: number }) => {
|
||||
if (typeof payload?.screenX !== "number" || typeof payload?.screenY !== "number") return;
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const [winX, winY] = win.getPosition();
|
||||
moveStates.set(win.id, { offsetX: payload.screenX - winX, offsetY: payload.screenY - winY });
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
"paseo:window:moving",
|
||||
(event, payload: { screenX: number; screenY: number }) => {
|
||||
if (typeof payload?.screenX !== "number" || typeof payload?.screenY !== "number") return;
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const state = moveStates.get(win.id);
|
||||
if (!state) return;
|
||||
win.setPosition(
|
||||
Math.round(payload.screenX - state.offsetX),
|
||||
Math.round(payload.screenY - state.offsetY)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on("paseo:window:endMove", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win) moveStates.delete(win.id);
|
||||
});
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
win.on("closed", () => moveStates.delete(win.id));
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
|
||||
|
||||
291
packages/server/src/server/session.workspace-git-watch.test.ts
Normal file
291
packages/server/src/server/session.workspace-git-watch.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
const { watchCalls, watchMock } = vi.hoisted(() => {
|
||||
const hoistedWatchCalls: Array<{
|
||||
path: string
|
||||
listener: () => void
|
||||
close: ReturnType<typeof vi.fn>
|
||||
}> = []
|
||||
|
||||
const hoistedWatchMock = vi.fn(
|
||||
(watchPath: string, _options: { recursive: boolean }, listener: () => void) => {
|
||||
const close = vi.fn()
|
||||
const watcher = {
|
||||
close,
|
||||
on: vi.fn().mockReturnThis(),
|
||||
}
|
||||
hoistedWatchCalls.push({
|
||||
path: watchPath,
|
||||
listener,
|
||||
close,
|
||||
})
|
||||
return watcher as any
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
watchCalls: hoistedWatchCalls,
|
||||
watchMock: hoistedWatchMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
|
||||
return {
|
||||
...actual,
|
||||
watch: watchMock,
|
||||
}
|
||||
})
|
||||
|
||||
import { Session } from './session.js'
|
||||
|
||||
function createSessionForWorkspaceGitWatchTests(): {
|
||||
session: Session
|
||||
emitted: Array<{ type: string; payload: unknown }>
|
||||
} {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const projects = new Map<string, any>()
|
||||
const workspaces = new Map<string, any>()
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}
|
||||
|
||||
const session = new Session({
|
||||
clientId: 'test-client',
|
||||
onMessage: (message) => emitted.push(message as any),
|
||||
logger: logger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: '/tmp/paseo-test',
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId: string) => projects.get(projectId) ?? null,
|
||||
upsert: async (record: any) => {
|
||||
projects.set(record.projectId, record)
|
||||
},
|
||||
archive: async (projectId: string, archivedAt: string) => {
|
||||
const existing = projects.get(projectId)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
projects.set(projectId, {
|
||||
...existing,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
})
|
||||
},
|
||||
remove: async (projectId: string) => {
|
||||
projects.delete(projectId)
|
||||
},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
||||
upsert: async (record: any) => {
|
||||
workspaces.set(record.workspaceId, record)
|
||||
},
|
||||
archive: async (workspaceId: string, archivedAt: string) => {
|
||||
const existing = workspaces.get(workspaceId)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
workspaces.set(workspaceId, {
|
||||
...existing,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
})
|
||||
},
|
||||
remove: async (workspaceId: string) => {
|
||||
workspaces.delete(workspaceId)
|
||||
},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error('not used')
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
}) as any
|
||||
|
||||
session.listAgentPayloads = async () => []
|
||||
|
||||
return {
|
||||
session,
|
||||
emitted,
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace git watch targets', () => {
|
||||
beforeEach(() => {
|
||||
watchCalls.length = 0
|
||||
watchMock.mockClear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('debounces watcher events and skips unchanged branch/diff snapshots', async () => {
|
||||
const { session, emitted } = createSessionForWorkspaceGitWatchTests()
|
||||
const sessionAny = session as any
|
||||
|
||||
sessionAny.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: 'repo',
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: 'main',
|
||||
remoteUrl: 'https://github.com/acme/repo.git',
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
})
|
||||
sessionAny.resolveCheckoutGitDir = async () => '/tmp/repo/.git'
|
||||
sessionAny.workspaceUpdatesSubscription = {
|
||||
subscriptionId: 'sub-1',
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
}
|
||||
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set()
|
||||
|
||||
let descriptor = {
|
||||
id: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'git',
|
||||
workspaceKind: 'local_checkout',
|
||||
name: 'main',
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
}
|
||||
|
||||
sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor]
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered('/tmp/repo')
|
||||
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor])
|
||||
|
||||
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
||||
'/tmp/repo/.git/HEAD',
|
||||
'/tmp/repo/.git/refs/heads',
|
||||
])
|
||||
|
||||
watchCalls[0]!.listener()
|
||||
watchCalls[1]!.listener()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(emitted.filter((message) => message.type === 'workspace_update')).toHaveLength(0)
|
||||
|
||||
descriptor = {
|
||||
...descriptor,
|
||||
name: 'renamed-branch',
|
||||
}
|
||||
watchCalls[0]!.listener()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
|
||||
expect(workspaceUpdates).toHaveLength(1)
|
||||
expect(workspaceUpdates[0]?.payload).toMatchObject({
|
||||
kind: 'upsert',
|
||||
workspace: {
|
||||
id: '/tmp/repo',
|
||||
name: 'renamed-branch',
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
descriptor = {
|
||||
...descriptor,
|
||||
diffStat: { additions: 3, deletions: 1 },
|
||||
}
|
||||
watchCalls[1]!.listener()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(emitted.filter((message) => message.type === 'workspace_update')).toHaveLength(2)
|
||||
|
||||
await session.cleanup()
|
||||
})
|
||||
|
||||
test('closes watchers when a workspace is archived and when the session closes', async () => {
|
||||
const { session } = createSessionForWorkspaceGitWatchTests()
|
||||
const sessionAny = session as any
|
||||
|
||||
sessionAny.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: path.basename(cwd),
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: 'main',
|
||||
remoteUrl: 'https://github.com/acme/repo.git',
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
})
|
||||
|
||||
sessionAny.resolveCheckoutGitDir = async (cwd: string) => path.join(cwd, '.git')
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered('/tmp/repo-one')
|
||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1)
|
||||
expect(watchCalls).toHaveLength(2)
|
||||
|
||||
await sessionAny.archiveWorkspaceRecord('/tmp/repo-one', '2026-03-21T00:00:00.000Z')
|
||||
|
||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(0)
|
||||
expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true)
|
||||
|
||||
watchCalls.length = 0
|
||||
watchMock.mockClear()
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered('/tmp/repo-two')
|
||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1)
|
||||
expect(watchCalls).toHaveLength(2)
|
||||
|
||||
await session.cleanup()
|
||||
|
||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(0)
|
||||
expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true)
|
||||
})
|
||||
|
||||
test('resolves refs from the shared git dir for linked worktrees', async () => {
|
||||
const { session } = createSessionForWorkspaceGitWatchTests()
|
||||
const sessionAny = session as any
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'session-workspace-git-watch-'))
|
||||
const gitDir = path.join(tempDir, 'repo', '.git', 'worktrees', 'feature')
|
||||
|
||||
mkdirSync(gitDir, { recursive: true })
|
||||
writeFileSync(path.join(gitDir, 'commondir'), '../..\n')
|
||||
|
||||
try {
|
||||
expect(await sessionAny.resolveWorkspaceGitRefsRoot(gitDir)).toBe(path.join(tempDir, 'repo', '.git'))
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
await session.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user