feat: add workspace file management and embedded browsing
Users previously had to leave the workbar to manage project files and inspect Git branches or commit history. Add file and directory creation, rename, move, delete and properties, plus searchable branches, branch creation and switching, explicit fetch, and commit file diffs. Keep file previews in the right workspace pane with directory expansion, selection, scroll and focus preserved on return. Add compact action menus, directory pickers, preview refresh and source switching. Embed the shared browser session directly in the workbar with navigation, resizing and fullscreen controls instead of a separate interaction window. Moves stay within the current workspace, deletion is permanent after confirmation, and Git switch reports conflicts without discarding or stashing changes. Typecheck, lint and staged whitespace checks pass; existing full-suite failures and the blocked real remote directory validation remain recorded in the workbar progress document. Release note: 可在右侧工作区管理文件、切换 Git 分支并查看提交历史,返回预览后保留浏览位置;浏览器支持在工作栏内直接交互和全屏显示。
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
remoteWorkspaceManageRequestSchema,
|
||||
remoteGitDiffRequestSchema,
|
||||
remoteGitDiffResultSchema,
|
||||
remoteGitStatusRequestSchema,
|
||||
@@ -25,6 +26,8 @@ import type {
|
||||
ProtocolMethodHandler
|
||||
} from './protocol-server'
|
||||
import type { WorkspaceGitService } from './workspace-git-service'
|
||||
import { manageWorkspace } from '../main/workspace/workspace-management'
|
||||
import { workspaceManagementResultSchema } from '../shared/workspace-management-contracts'
|
||||
import type { WorkspaceRegistry } from './workspace-registry'
|
||||
import {
|
||||
WorkspaceServiceError,
|
||||
@@ -35,6 +38,7 @@ const DEFAULT_WORKSPACE_REQUEST_TIMEOUT_MS = 30_000
|
||||
const MAXIMUM_WORKSPACE_REQUEST_TIMEOUT_MS = 120_000
|
||||
|
||||
export const WORKSPACE_PROTOCOL_METHODS = [
|
||||
'workspace/manage',
|
||||
'workspace/validate',
|
||||
'workspace/open',
|
||||
'workspace/resume',
|
||||
@@ -77,6 +81,15 @@ export function createWorkspaceProtocolMethods(options: {
|
||||
}
|
||||
|
||||
return {
|
||||
'workspace/manage': async (params, context) => {
|
||||
const request = remoteWorkspaceManageRequestSchema.parse(params)
|
||||
return await invoke(context, async (io) => {
|
||||
const workspace = await options.workspaces.get(request.workspaceId, request.generation, context.controller, io)
|
||||
if (workspace.handle.access !== 'read-write') throw new WorkspaceServiceError('Workspace management requires read-write access', 'read-only')
|
||||
await workspace.access.assertCurrent(io)
|
||||
return workspaceManagementResultSchema.parse(await manageWorkspace(workspace.access.root.canonicalPath, request.action, io.signal))
|
||||
})
|
||||
},
|
||||
'workspace/validate': async (params, context) =>
|
||||
await invoke(context, async (io) =>
|
||||
remoteWorkspaceValidateResultSchema.parse(
|
||||
|
||||
@@ -161,7 +161,8 @@ export class WorkspaceRegistry {
|
||||
controller,
|
||||
true,
|
||||
undefined,
|
||||
options
|
||||
options,
|
||||
request.requestedAccess
|
||||
)
|
||||
} catch (error) {
|
||||
if (existing === undefined) {
|
||||
@@ -413,7 +414,8 @@ export class WorkspaceRegistry {
|
||||
controller: ControllerLease,
|
||||
authorizesOpen: boolean,
|
||||
grantWorkspaceId: string | undefined,
|
||||
options: WorkspaceIoOptions
|
||||
options: WorkspaceIoOptions,
|
||||
requestedAccess: 'read-only' | 'read-write' = 'read-only'
|
||||
): Promise<WorkspaceLease> {
|
||||
this.#assertCapacity(controller.controllerId)
|
||||
await root.access.assertCurrent(options)
|
||||
@@ -439,7 +441,7 @@ export class WorkspaceRegistry {
|
||||
workspaceId,
|
||||
workspaceIdentity,
|
||||
canonicalDisplayPath: root.access.root.canonicalPath,
|
||||
access: 'read-only',
|
||||
access: requestedAccess,
|
||||
git: root.git,
|
||||
capabilities: capabilitiesFor(root.git),
|
||||
generation
|
||||
@@ -486,11 +488,10 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
|
||||
#assertReadOnly(
|
||||
requestedAccess: 'read-only' | 'read-write',
|
||||
_requestedAccess: 'read-only' | 'read-write',
|
||||
capabilities: readonly string[]
|
||||
): void {
|
||||
if (
|
||||
requestedAccess !== 'read-only' ||
|
||||
capabilities.includes('write-text-atomic') ||
|
||||
capabilities.includes('apply-change-set')
|
||||
) {
|
||||
|
||||
@@ -14,6 +14,7 @@ type HarnessSlot = {
|
||||
currentUrl?: string
|
||||
approvedOrigin?: string
|
||||
emitLoading(isLoading: boolean): void
|
||||
emitNavigation(url: string): void
|
||||
session: BrowserSessionLike
|
||||
driver: BrowserDriverLike
|
||||
}
|
||||
@@ -55,6 +56,7 @@ function createHarness(options: {
|
||||
getURL: () => slot.currentUrl ?? `${slot.currentOrigin}/page`
|
||||
} as BrowserWebContents
|
||||
const loadingListeners = new Set<(isLoading: boolean) => void>()
|
||||
const navigationListeners = new Set<(url: string) => void>()
|
||||
let isLoading = false
|
||||
const session: BrowserSessionLike = {
|
||||
webContents,
|
||||
@@ -67,7 +69,11 @@ function createHarness(options: {
|
||||
loadingListeners.add(listener)
|
||||
return () => loadingListeners.delete(listener)
|
||||
}),
|
||||
openInteraction: vi.fn(async () => undefined),
|
||||
onNavigationChange: vi.fn((listener) => {
|
||||
navigationListeners.add(listener)
|
||||
return () => navigationListeners.delete(listener)
|
||||
}),
|
||||
setViewport: vi.fn(),
|
||||
stopLoading: vi.fn(),
|
||||
...(options.captureScreenshot
|
||||
? { captureScreenshot: vi.fn(options.captureScreenshot) }
|
||||
@@ -123,6 +129,13 @@ function createHarness(options: {
|
||||
for (const listener of loadingListeners) {
|
||||
listener(loading)
|
||||
}
|
||||
},
|
||||
emitNavigation: (url: string) => {
|
||||
slot.currentUrl = url
|
||||
slot.currentOrigin = canonicalizeBrowserUrl(url).origin
|
||||
for (const listener of navigationListeners) {
|
||||
listener(url)
|
||||
}
|
||||
}
|
||||
})
|
||||
slots.push(slot)
|
||||
@@ -152,6 +165,42 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('BrowserService', () => {
|
||||
it('presents one session in the shared viewport and tracks user navigation', async () => {
|
||||
const harness = createHarness()
|
||||
const bounds = { x: 900, y: 120, width: 420, height: 640 }
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
harness.service.setViewport('conversation', bounds)
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
const slot = harness.slots[0]
|
||||
expect(slot?.session.setViewport).toHaveBeenCalledWith(bounds)
|
||||
|
||||
vi.mocked(slot!.driver.getNavigationMetadata).mockResolvedValueOnce({
|
||||
url: 'https://example.com/account',
|
||||
canGoBack: true
|
||||
})
|
||||
slot?.emitNavigation('https://example.com/account')
|
||||
await vi.waitFor(() => {
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
url: 'https://example.com/account',
|
||||
canGoBack: true
|
||||
})
|
||||
})
|
||||
expect(harness.service.getOrigin('conversation')).toBe(
|
||||
'https://example.com'
|
||||
)
|
||||
|
||||
harness.service.setViewport()
|
||||
expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined)
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('publishes browser status and live frames through session cleanup', async () => {
|
||||
const harness = createHarness()
|
||||
const states: Array<{
|
||||
@@ -464,58 +513,6 @@ describe('BrowserService', () => {
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('pauses agent operations while the user interacts with the same session', async () => {
|
||||
const harness = createHarness()
|
||||
const signal = new AbortController().signal
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://a.example/',
|
||||
signal
|
||||
)
|
||||
const interactionGate = deferred<
|
||||
Awaited<ReturnType<BrowserSessionLike['openInteraction']>>
|
||||
>()
|
||||
const slot = harness.slots[0]
|
||||
if (!slot) {
|
||||
throw new Error('slot missing')
|
||||
}
|
||||
vi.mocked(slot.session.openInteraction).mockReturnValueOnce(
|
||||
interactionGate.promise
|
||||
)
|
||||
|
||||
const interaction = harness.service.interact(
|
||||
'conversation',
|
||||
signal
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(slot.session.openInteraction).toHaveBeenCalledOnce()
|
||||
)
|
||||
const snapshot = harness.service.snapshot('conversation', signal)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(slot.driver.snapshot).not.toHaveBeenCalled()
|
||||
|
||||
interactionGate.resolve({
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'closing-frame'
|
||||
})
|
||||
await interaction
|
||||
expect(states.slice(-2).map((state) => state.status)).toEqual([
|
||||
'interactive',
|
||||
'ready'
|
||||
])
|
||||
expect(states.at(-1)?.frameDataUrl).toBe(
|
||||
'data:image/jpeg;base64,closing-frame'
|
||||
)
|
||||
expect(harness.service.getSessionCount()).toBe(1)
|
||||
expect(slot.session.dispose).not.toHaveBeenCalled()
|
||||
await snapshot
|
||||
expect(slot.driver.snapshot).toHaveBeenCalledOnce()
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('does not let a canceled queued waiter clear the active operation owner', async () => {
|
||||
const harness = createHarness()
|
||||
const signal = new AbortController().signal
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { BrowserScreenshot } from './browser-screenshot'
|
||||
import {
|
||||
ElectronBrowserSession,
|
||||
type BrowserParentWindowHandle,
|
||||
type BrowserWindowBounds,
|
||||
type BrowserWebContents
|
||||
} from './electron-browser-session'
|
||||
import type { BrowserLiveState } from '../../shared/contracts'
|
||||
@@ -31,7 +32,8 @@ export type BrowserSessionLike = {
|
||||
getCurrentOrigin(): string | undefined
|
||||
isLoading(): boolean
|
||||
onLoadingChange(listener: (isLoading: boolean) => void): () => void
|
||||
openInteraction(): Promise<BrowserScreenshot | undefined>
|
||||
onNavigationChange?(listener: (url: string) => void): () => void
|
||||
setViewport?(bounds?: BrowserWindowBounds): void
|
||||
stopLoading(): void
|
||||
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
|
||||
dispose(): Promise<void>
|
||||
@@ -84,6 +86,7 @@ type BrowserSlot = {
|
||||
isLoading: boolean
|
||||
stopLoadingRequested: boolean
|
||||
removeLoadingListener: () => void
|
||||
removeNavigationListener: () => void
|
||||
idleTimer?: ReturnType<typeof setTimeout>
|
||||
lastUsedAt: number
|
||||
released: boolean
|
||||
@@ -172,6 +175,8 @@ export class BrowserService {
|
||||
(state: BrowserLiveState) => void
|
||||
>()
|
||||
private readonly liveStates = new Map<string, BrowserLiveState>()
|
||||
private visibleConversationId?: string
|
||||
private viewportBounds?: BrowserWindowBounds
|
||||
private lifecycle = new AbortController()
|
||||
private clearOperation?: Promise<void>
|
||||
private clearing = false
|
||||
@@ -221,6 +226,19 @@ export class BrowserService {
|
||||
}
|
||||
}
|
||||
|
||||
setViewport(
|
||||
conversationId?: string,
|
||||
bounds?: BrowserWindowBounds
|
||||
): void {
|
||||
this.visibleConversationId = conversationId
|
||||
this.viewportBounds = bounds
|
||||
for (const [id, slot] of this.slots) {
|
||||
slot.session.setViewport?.(
|
||||
id === conversationId ? bounds : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private emitState(
|
||||
conversationId: string,
|
||||
status: BrowserLiveState['status'],
|
||||
@@ -500,10 +518,16 @@ export class BrowserService {
|
||||
isLoading: session.isLoading(),
|
||||
stopLoadingRequested: false,
|
||||
removeLoadingListener: () => undefined,
|
||||
removeNavigationListener: () => undefined,
|
||||
lastUsedAt: Date.now(),
|
||||
released: false
|
||||
}
|
||||
this.slots.set(conversationId, slot)
|
||||
slot.session.setViewport?.(
|
||||
this.visibleConversationId === conversationId
|
||||
? this.viewportBounds
|
||||
: undefined
|
||||
)
|
||||
slot.removeLoadingListener = session.onLoadingChange((isLoading) => {
|
||||
if (!isLoading) {
|
||||
slot.stopLoadingRequested = false
|
||||
@@ -518,9 +542,49 @@ export class BrowserService {
|
||||
slot.isLoading = isLoading
|
||||
const current = this.liveStates.get(conversationId)
|
||||
if (current) {
|
||||
this.emitState(conversationId, current.status, { isLoading })
|
||||
const status =
|
||||
slot.active?.status ??
|
||||
(isLoading
|
||||
? 'loading'
|
||||
: current.status === 'loading'
|
||||
? 'ready'
|
||||
: current.status)
|
||||
this.emitState(conversationId, status, { isLoading })
|
||||
}
|
||||
})
|
||||
slot.removeNavigationListener =
|
||||
session.onNavigationChange?.((url) => {
|
||||
if (slot.released || this.slots.get(conversationId) !== slot) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const target = canonicalizeBrowserUrl(url)
|
||||
slot.origin = target.origin
|
||||
slot.lastUsedAt = Date.now()
|
||||
this.scheduleIdleExpiry(slot)
|
||||
const status = slot.active?.status ?? 'ready'
|
||||
this.emitState(conversationId, status, { url: target.href })
|
||||
void slot.driver
|
||||
.getNavigationMetadata(AbortSignal.timeout(2_000))
|
||||
.then((metadata) => {
|
||||
if (
|
||||
slot.released ||
|
||||
this.slots.get(conversationId) !== slot ||
|
||||
canonicalizeBrowserUrl(metadata.url).href !== target.href
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.emitState(
|
||||
conversationId,
|
||||
slot.active?.status ?? 'ready',
|
||||
{ url: target.href, canGoBack: metadata.canGoBack }
|
||||
)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
} catch {
|
||||
// The session rejects unsupported top-level URLs.
|
||||
}
|
||||
}) ?? (() => undefined)
|
||||
this.scheduleIdleExpiry(slot)
|
||||
return slot
|
||||
}
|
||||
@@ -878,37 +942,6 @@ export class BrowserService {
|
||||
)
|
||||
}
|
||||
|
||||
async interact(
|
||||
conversationId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
await this.runInSession(
|
||||
conversationId,
|
||||
signal,
|
||||
'interactive',
|
||||
false,
|
||||
'浏览器交互',
|
||||
async (slot, effectiveSignal) => {
|
||||
await this.verifyCurrentOriginOrRelease(slot)
|
||||
const closingFrame = await waitFor(
|
||||
slot.session.openInteraction(),
|
||||
effectiveSignal
|
||||
)
|
||||
const currentUrl = canonicalizeBrowserUrl(
|
||||
slot.session.webContents.getURL()
|
||||
)
|
||||
slot.origin = currentUrl.origin
|
||||
await this.captureFrame(
|
||||
conversationId,
|
||||
slot,
|
||||
effectiveSignal,
|
||||
currentUrl.href,
|
||||
closingFrame
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async stopLoading(conversationId: string): Promise<boolean> {
|
||||
const slot = this.slots.get(conversationId)
|
||||
const active = slot?.active
|
||||
@@ -1043,6 +1076,7 @@ export class BrowserService {
|
||||
slot.idleTimer = undefined
|
||||
}
|
||||
slot.removeLoadingListener()
|
||||
slot.removeNavigationListener()
|
||||
slot.active?.controller.abort(new Error('浏览器会话已释放'))
|
||||
try {
|
||||
slot.driver.dispose()
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ElectronBrowserSession,
|
||||
type BrowserPartitionSession,
|
||||
type BrowserWebContents,
|
||||
type BrowserWindowHandle,
|
||||
type BrowserViewHandle,
|
||||
type FilteringProxyLike
|
||||
} from './electron-browser-session'
|
||||
|
||||
@@ -21,7 +21,6 @@ function createHarness() {
|
||||
const debuggerEvents = new EventEmitter()
|
||||
const contentEvents = new EventEmitter()
|
||||
const partitionEvents = new EventEmitter()
|
||||
const windowEvents = new EventEmitter()
|
||||
let currentUrl = ''
|
||||
let loadingMainFrame = false
|
||||
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
|
||||
@@ -63,34 +62,20 @@ function createHarness() {
|
||||
openHandler = handler
|
||||
}),
|
||||
capturePage: vi.fn(async () => capturedImage),
|
||||
loadURL: vi.fn(async (url: string) => {
|
||||
currentUrl = url
|
||||
}),
|
||||
getURL: vi.fn(() => currentUrl),
|
||||
isLoadingMainFrame: vi.fn(() => loadingMainFrame),
|
||||
stop: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
const window: BrowserWindowHandle = {
|
||||
const view: BrowserViewHandle = {
|
||||
nativeView: {},
|
||||
webContents,
|
||||
loadURL: vi.fn(async (url: string) => {
|
||||
currentUrl = url
|
||||
}),
|
||||
show: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
isMinimized: vi.fn(() => false),
|
||||
focus: vi.fn(),
|
||||
on: (event, listener) =>
|
||||
windowEvents.on(
|
||||
event,
|
||||
listener as (...argumentsValue: unknown[]) => void
|
||||
),
|
||||
off: (event, listener) =>
|
||||
windowEvents.off(
|
||||
event,
|
||||
listener as (...argumentsValue: unknown[]) => void
|
||||
),
|
||||
destroy: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
setVisible: vi.fn(),
|
||||
setBounds: vi.fn(),
|
||||
}
|
||||
let permissionCheck: ((...values: unknown[]) => boolean) | undefined
|
||||
let permissionRequest:
|
||||
@@ -143,13 +128,12 @@ function createHarness() {
|
||||
contentEvents,
|
||||
debuggerEvents,
|
||||
partitionEvents,
|
||||
windowEvents,
|
||||
partition,
|
||||
proxy,
|
||||
policy,
|
||||
sendCommand,
|
||||
webContents,
|
||||
window,
|
||||
window: view,
|
||||
setCurrentUrl(value: string) {
|
||||
currentUrl = value
|
||||
},
|
||||
@@ -166,7 +150,7 @@ function createHarness() {
|
||||
describe('ElectronBrowserSession', () => {
|
||||
it('creates an isolated sandboxed partition and denies privileged capabilities', async () => {
|
||||
const harness = createHarness()
|
||||
const createWindow = vi.fn(async (options: Record<string, unknown>) => {
|
||||
const createView = vi.fn(async (options: Record<string, unknown>) => {
|
||||
const preferences = options.webPreferences as Record<string, unknown>
|
||||
expect(preferences).toMatchObject({
|
||||
sandbox: true,
|
||||
@@ -181,7 +165,7 @@ describe('ElectronBrowserSession', () => {
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: vi.fn(async () => harness.partition),
|
||||
createWindow,
|
||||
createView,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
@@ -208,9 +192,9 @@ describe('ElectronBrowserSession', () => {
|
||||
expect(harness.getOpenHandler()?.({ url: 'https://example.com' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
expect(harness.window.loadURL).toHaveBeenCalledWith('about:blank')
|
||||
expect(harness.webContents.loadURL).toHaveBeenCalledWith('about:blank')
|
||||
expect(
|
||||
vi.mocked(harness.window.loadURL).mock.invocationCallOrder[0]
|
||||
vi.mocked(harness.webContents.loadURL!).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
vi.mocked(harness.webContents.debugger.attach).mock
|
||||
.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
@@ -230,6 +214,16 @@ describe('ElectronBrowserSession', () => {
|
||||
})
|
||||
session.stopLoading()
|
||||
expect(harness.webContents.stop).toHaveBeenCalledOnce()
|
||||
session.setViewport({ x: 900, y: 120, width: 420, height: 640 })
|
||||
expect(harness.window.setBounds).toHaveBeenCalledWith({
|
||||
x: 900,
|
||||
y: 120,
|
||||
width: 420,
|
||||
height: 640
|
||||
})
|
||||
expect(harness.window.setVisible).toHaveBeenLastCalledWith(true)
|
||||
session.setViewport()
|
||||
expect(harness.window.setVisible).toHaveBeenLastCalledWith(false)
|
||||
|
||||
const downloadEvent = { preventDefault: vi.fn() }
|
||||
const item = { cancel: vi.fn() }
|
||||
@@ -256,7 +250,7 @@ describe('ElectronBrowserSession', () => {
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
const target = await harness.policy.validate(
|
||||
@@ -306,7 +300,7 @@ describe('ElectronBrowserSession', () => {
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
expect(session.isLoading()).toBe(true)
|
||||
@@ -328,72 +322,30 @@ describe('ElectronBrowserSession', () => {
|
||||
await session.dispose()
|
||||
})
|
||||
|
||||
it('restores the browser for interaction and minimizes it on close', async () => {
|
||||
it('attaches the browser view to the parent content tree and removes it on dispose', async () => {
|
||||
const harness = createHarness()
|
||||
const parentWindow = {
|
||||
setEnabled: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
contentView: {
|
||||
addChildView: vi.fn(),
|
||||
removeChildView: vi.fn()
|
||||
},
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
let createdWindowOptions: Record<string, unknown> | undefined
|
||||
const createWindow = vi.fn(
|
||||
async (options: Record<string, unknown>) => {
|
||||
createdWindowOptions = options
|
||||
return harness.window
|
||||
}
|
||||
)
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
parentWindow,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
expect(createWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: parentWindow,
|
||||
show: false,
|
||||
title: 'GoodBuddy 浏览器交互'
|
||||
})
|
||||
expect(parentWindow.contentView.addChildView).toHaveBeenCalledWith(
|
||||
harness.window.nativeView
|
||||
)
|
||||
expect(createdWindowOptions?.modal).toBeUndefined()
|
||||
const interaction = session.openInteraction()
|
||||
expect(parentWindow.setEnabled).toHaveBeenCalledWith(false)
|
||||
expect(harness.window.show).toHaveBeenCalledOnce()
|
||||
expect(harness.window.focus).toHaveBeenCalledOnce()
|
||||
const closeEvent = { preventDefault: vi.fn() }
|
||||
harness.windowEvents.emit('close', closeEvent)
|
||||
|
||||
await expect(interaction).resolves.toEqual({
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
})
|
||||
expect(closeEvent.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(harness.webContents.capturePage).toHaveBeenCalledOnce()
|
||||
expect(harness.window.minimize).toHaveBeenCalledOnce()
|
||||
expect(parentWindow.setEnabled).toHaveBeenLastCalledWith(true)
|
||||
expect(parentWindow.focus).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
vi.mocked(harness.webContents.capturePage!).mock
|
||||
.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
vi.mocked(harness.window.minimize).mock.invocationCallOrder[0] ??
|
||||
Number.POSITIVE_INFINITY
|
||||
)
|
||||
const repeatedCloseEvent = { preventDefault: vi.fn() }
|
||||
harness.windowEvents.emit('close', repeatedCloseEvent)
|
||||
expect(repeatedCloseEvent.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(harness.window.minimize).toHaveBeenCalledTimes(2)
|
||||
expect(harness.window.destroy).not.toHaveBeenCalled()
|
||||
vi.mocked(harness.window.isMinimized).mockReturnValue(true)
|
||||
const reopenedInteraction = session.openInteraction()
|
||||
expect(harness.window.restore).toHaveBeenCalledOnce()
|
||||
harness.windowEvents.emit('close', { preventDefault: vi.fn() })
|
||||
await reopenedInteraction
|
||||
await session.dispose()
|
||||
expect(harness.window.destroy).toHaveBeenCalledOnce()
|
||||
expect(parentWindow.contentView.removeChildView).toHaveBeenCalledWith(
|
||||
harness.window.nativeView
|
||||
)
|
||||
})
|
||||
|
||||
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
|
||||
@@ -401,14 +353,14 @@ describe('ElectronBrowserSession', () => {
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
await session.dispose()
|
||||
await session.dispose()
|
||||
|
||||
expect(harness.webContents.debugger.detach).toHaveBeenCalledOnce()
|
||||
expect(harness.window.destroy).toHaveBeenCalledOnce()
|
||||
expect(harness.webContents.destroy).toHaveBeenCalledOnce()
|
||||
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
|
||||
expect(harness.partition.clearData).toHaveBeenCalledOnce()
|
||||
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
|
||||
@@ -423,11 +375,11 @@ describe('ElectronBrowserSession', () => {
|
||||
ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
).rejects.toThrow('无法创建安全浏览器会话')
|
||||
expect(harness.window.destroy).toHaveBeenCalled()
|
||||
expect(harness.webContents.destroy).toHaveBeenCalled()
|
||||
expect(harness.partition.clearData).toHaveBeenCalled()
|
||||
expect(harness.proxy.dispose).toHaveBeenCalled()
|
||||
})
|
||||
@@ -439,7 +391,7 @@ describe('ElectronBrowserSession', () => {
|
||||
policy: harness.policy,
|
||||
setupTimeoutMs: 5,
|
||||
createPartition: () => partitionGate.promise,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
@@ -451,20 +403,22 @@ describe('ElectronBrowserSession', () => {
|
||||
expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('destroys a hidden window that resolves after setup times out', async () => {
|
||||
it('destroys a detached view that resolves after setup times out', async () => {
|
||||
const harness = createHarness()
|
||||
const windowGate = deferred<BrowserWindowHandle>()
|
||||
const viewGate = deferred<BrowserViewHandle>()
|
||||
const creation = ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
setupTimeoutMs: 5,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: () => windowGate.promise,
|
||||
createView: () => viewGate.promise,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
await expect(creation).rejects.toThrow('无法创建安全浏览器会话')
|
||||
windowGate.resolve(harness.window)
|
||||
await vi.waitFor(() => expect(harness.window.destroy).toHaveBeenCalledOnce())
|
||||
viewGate.resolve(harness.window)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.webContents.destroy).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(harness.partition.clearData).toHaveBeenCalledOnce()
|
||||
expect(harness.proxy.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -480,7 +434,7 @@ describe('ElectronBrowserSession', () => {
|
||||
policy: harness.policy,
|
||||
setupTimeoutMs: 5,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow: async () => harness.window,
|
||||
createView: async () => harness.window,
|
||||
createProxy: () => proxy
|
||||
})
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export type BrowserWebContents = {
|
||||
handler: (details: { url: string }) => { action: 'deny' }
|
||||
): void
|
||||
capturePage?(): Promise<BrowserCapturedImage>
|
||||
loadURL?(url: string): Promise<unknown>
|
||||
getURL(): string
|
||||
isLoadingMainFrame?(): boolean
|
||||
stop(): void
|
||||
@@ -47,26 +48,28 @@ export type BrowserWebContents = {
|
||||
isDestroyed(): boolean
|
||||
}
|
||||
|
||||
export type BrowserWindowHandle = {
|
||||
export type BrowserViewHandle = {
|
||||
webContents: BrowserWebContents
|
||||
loadURL(url: string): Promise<unknown>
|
||||
show(): void
|
||||
minimize(): void
|
||||
restore(): void
|
||||
isMinimized(): boolean
|
||||
focus(): void
|
||||
on(event: string, listener: BrowserEventListener): unknown
|
||||
off(event: string, listener: BrowserEventListener): unknown
|
||||
destroy(): void
|
||||
isDestroyed(): boolean
|
||||
nativeView?: unknown
|
||||
setVisible(visible: boolean): void
|
||||
setBounds(bounds: BrowserWindowBounds): void
|
||||
}
|
||||
|
||||
export type BrowserParentWindowHandle = {
|
||||
setEnabled?(enabled: boolean): void
|
||||
focus?(): void
|
||||
contentView?: {
|
||||
addChildView(view: unknown): void
|
||||
removeChildView(view: unknown): void
|
||||
}
|
||||
isDestroyed?(): boolean
|
||||
}
|
||||
|
||||
export type BrowserWindowBounds = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type BrowserPartitionSession = {
|
||||
setPermissionCheckHandler(
|
||||
handler: (...argumentsValue: never[]) => boolean
|
||||
@@ -110,9 +113,9 @@ export type ElectronBrowserSessionOptions = {
|
||||
cleanupTimeoutMs?: number
|
||||
setupTimeoutMs?: number
|
||||
createPartition?: (partition: string) => Promise<BrowserPartitionSession>
|
||||
createWindow?: (
|
||||
createView?: (
|
||||
options: Record<string, unknown>
|
||||
) => Promise<BrowserWindowHandle>
|
||||
) => Promise<BrowserViewHandle>
|
||||
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
|
||||
parentWindow?: BrowserParentWindowHandle
|
||||
}
|
||||
@@ -214,11 +217,18 @@ async function defaultCreatePartition(
|
||||
) as unknown as BrowserPartitionSession
|
||||
}
|
||||
|
||||
async function defaultCreateWindow(
|
||||
async function defaultCreateView(
|
||||
options: Record<string, unknown>
|
||||
): Promise<BrowserWindowHandle> {
|
||||
): Promise<BrowserViewHandle> {
|
||||
const electron = await import('electron')
|
||||
return new electron.BrowserWindow(options) as unknown as BrowserWindowHandle
|
||||
const view = new electron.WebContentsView(options)
|
||||
view.setVisible(false)
|
||||
return {
|
||||
nativeView: view,
|
||||
webContents: view.webContents as unknown as BrowserWebContents,
|
||||
setVisible: (visible) => view.setVisible(visible),
|
||||
setBounds: (bounds) => view.setBounds(bounds)
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectronBrowserSession {
|
||||
@@ -226,26 +236,22 @@ export class ElectronBrowserSession {
|
||||
readonly webContents: BrowserWebContents
|
||||
private approvedOrigin?: string
|
||||
private readonly listeners: Listener[] = []
|
||||
private interaction?: {
|
||||
promise: Promise<BrowserScreenshot | undefined>
|
||||
resolve(frame?: BrowserScreenshot): void
|
||||
}
|
||||
private interactionClosing?: Promise<void>
|
||||
private readonly loadingListeners = new Set<(isLoading: boolean) => void>()
|
||||
private readonly navigationListeners = new Set<(url: string) => void>()
|
||||
private loading = false
|
||||
private disposed = false
|
||||
|
||||
private constructor(
|
||||
private readonly policy: BrowserUrlPolicy,
|
||||
private readonly partitionSession: BrowserPartitionSession,
|
||||
private readonly window: BrowserWindowHandle,
|
||||
private readonly view: BrowserViewHandle,
|
||||
private readonly proxy: FilteringProxyLike,
|
||||
partition: string,
|
||||
private readonly cleanupTimeoutMs: number,
|
||||
private readonly parentWindow?: BrowserParentWindowHandle
|
||||
) {
|
||||
this.partition = partition
|
||||
this.webContents = window.webContents
|
||||
this.webContents = view.webContents
|
||||
}
|
||||
|
||||
static async create(
|
||||
@@ -254,7 +260,7 @@ export class ElectronBrowserSession {
|
||||
): Promise<ElectronBrowserSession> {
|
||||
const partition = `browser-${randomUUID()}`
|
||||
const createPartition = options.createPartition ?? defaultCreatePartition
|
||||
const createWindow = options.createWindow ?? defaultCreateWindow
|
||||
const createView = options.createView ?? defaultCreateView
|
||||
const cleanupTimeoutMs = options.cleanupTimeoutMs ?? 5_000
|
||||
const setupTimeoutMs = options.setupTimeoutMs ?? 15_000
|
||||
if (!Number.isSafeInteger(cleanupTimeoutMs) || cleanupTimeoutMs < 1) {
|
||||
@@ -275,7 +281,8 @@ export class ElectronBrowserSession {
|
||||
}
|
||||
}
|
||||
let partitionSession: BrowserPartitionSession | undefined
|
||||
let window: BrowserWindowHandle | undefined
|
||||
let view: BrowserViewHandle | undefined
|
||||
let viewAttached = false
|
||||
let result: ElectronBrowserSession | undefined
|
||||
let setupStage = '启动代理'
|
||||
try {
|
||||
@@ -318,19 +325,9 @@ export class ElectronBrowserSession {
|
||||
signal,
|
||||
setupTimeoutMs
|
||||
)
|
||||
setupStage = '创建浏览器窗口'
|
||||
window = await boundedSetup(
|
||||
createWindow({
|
||||
show: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
title: 'GoodBuddy 浏览器交互',
|
||||
autoHideMenuBar: true,
|
||||
...(options.parentWindow
|
||||
? {
|
||||
parent: options.parentWindow
|
||||
}
|
||||
: {}),
|
||||
setupStage = '创建浏览器视图'
|
||||
view = await boundedSetup(
|
||||
createView({
|
||||
webPreferences: {
|
||||
partition,
|
||||
sandbox: true,
|
||||
@@ -348,24 +345,32 @@ export class ElectronBrowserSession {
|
||||
}),
|
||||
signal,
|
||||
setupTimeoutMs,
|
||||
(lateWindow) => {
|
||||
if (!lateWindow.isDestroyed()) {
|
||||
lateWindow.destroy()
|
||||
} else if (!lateWindow.webContents.isDestroyed()) {
|
||||
lateWindow.webContents.destroy()
|
||||
(lateView) => {
|
||||
if (!lateView.webContents.isDestroyed()) {
|
||||
lateView.webContents.close?.({ waitForBeforeUnload: false })
|
||||
if (!lateView.webContents.isDestroyed()) {
|
||||
lateView.webContents.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (options.parentWindow?.contentView && view.nativeView) {
|
||||
options.parentWindow.contentView.addChildView(view.nativeView)
|
||||
viewAttached = true
|
||||
}
|
||||
setupStage = '加载初始页面'
|
||||
if (!view.webContents.loadURL) {
|
||||
throw new Error('浏览器视图导航不可用')
|
||||
}
|
||||
await boundedSetup(
|
||||
window.loadURL('about:blank'),
|
||||
view.webContents.loadURL('about:blank'),
|
||||
signal,
|
||||
setupTimeoutMs
|
||||
)
|
||||
result = new ElectronBrowserSession(
|
||||
options.policy,
|
||||
partitionSession,
|
||||
window,
|
||||
view,
|
||||
managedProxy,
|
||||
partition,
|
||||
cleanupTimeoutMs,
|
||||
@@ -377,8 +382,16 @@ export class ElectronBrowserSession {
|
||||
} catch (error) {
|
||||
if (result) {
|
||||
await result.dispose().catch(() => undefined)
|
||||
} else if (window && !window.isDestroyed()) {
|
||||
window.destroy()
|
||||
} else if (view) {
|
||||
if (viewAttached && view.nativeView) {
|
||||
options.parentWindow?.contentView?.removeChildView(view.nativeView)
|
||||
}
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
view.webContents.close?.({ waitForBeforeUnload: false })
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
view.webContents.destroy()
|
||||
}
|
||||
}
|
||||
await cleanupIsolatedState(
|
||||
partitionSession,
|
||||
managedProxy,
|
||||
@@ -422,21 +435,6 @@ export class ElectronBrowserSession {
|
||||
this.listen(contents, 'did-stop-loading', () => {
|
||||
this.setLoading(contents.isLoadingMainFrame?.() ?? false)
|
||||
})
|
||||
this.listen(
|
||||
this.window,
|
||||
'close',
|
||||
(event: { preventDefault(): void }) => {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (this.interaction) {
|
||||
void this.captureAndFinishInteraction()
|
||||
} else {
|
||||
this.window.minimize()
|
||||
}
|
||||
}
|
||||
)
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
|
||||
const url = typeof details === 'string' ? details : details.url
|
||||
@@ -471,6 +469,15 @@ export class ElectronBrowserSession {
|
||||
this.listen(contents, 'did-navigate', (_event: unknown, url: string) => {
|
||||
if (url && !this.updateOriginFromUrl(url)) {
|
||||
contents.stop()
|
||||
return
|
||||
}
|
||||
if (url) {
|
||||
this.emitNavigation(url)
|
||||
}
|
||||
})
|
||||
this.listen(contents, 'did-navigate-in-page', (_event: unknown, url: string) => {
|
||||
if (url) {
|
||||
this.emitNavigation(url)
|
||||
}
|
||||
})
|
||||
this.listen(
|
||||
@@ -553,6 +560,30 @@ export class ElectronBrowserSession {
|
||||
}
|
||||
}
|
||||
|
||||
onNavigationChange(listener: (url: string) => void): () => void {
|
||||
this.assertOpen()
|
||||
this.navigationListeners.add(listener)
|
||||
return () => {
|
||||
this.navigationListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private emitNavigation(url: string): void {
|
||||
for (const listener of this.navigationListeners) {
|
||||
listener(url)
|
||||
}
|
||||
}
|
||||
|
||||
setViewport(bounds?: BrowserWindowBounds): void {
|
||||
this.assertOpen()
|
||||
if (!bounds) {
|
||||
this.view.setVisible(false)
|
||||
return
|
||||
}
|
||||
this.view.setBounds(bounds)
|
||||
this.view.setVisible(true)
|
||||
}
|
||||
|
||||
private setLoading(isLoading: boolean): void {
|
||||
if (this.loading === isLoading) {
|
||||
return
|
||||
@@ -594,91 +625,6 @@ export class ElectronBrowserSession {
|
||||
this.approvedOrigin = target.origin
|
||||
}
|
||||
|
||||
openInteraction(): Promise<BrowserScreenshot | undefined> {
|
||||
this.assertOpen()
|
||||
if (this.interaction) {
|
||||
this.setParentEnabled(false)
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore()
|
||||
}
|
||||
this.window.show()
|
||||
this.window.focus()
|
||||
return this.interaction.promise
|
||||
}
|
||||
let resolve!: (frame?: BrowserScreenshot) => void
|
||||
const promise = new Promise<BrowserScreenshot | undefined>(
|
||||
(resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
}
|
||||
)
|
||||
this.interaction = { promise, resolve }
|
||||
this.setParentEnabled(false)
|
||||
try {
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore()
|
||||
}
|
||||
this.window.show()
|
||||
this.window.focus()
|
||||
} catch (error) {
|
||||
this.finishInteraction()
|
||||
throw error
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
private captureAndFinishInteraction(): Promise<void> {
|
||||
if (this.interactionClosing) {
|
||||
return this.interactionClosing
|
||||
}
|
||||
const operation = (async (): Promise<void> => {
|
||||
let frame: BrowserScreenshot | undefined
|
||||
try {
|
||||
frame = await this.captureScreenshot(AbortSignal.timeout(2_000))
|
||||
} catch {
|
||||
// The session remains usable even if the final visible frame fails.
|
||||
}
|
||||
try {
|
||||
if (!this.disposed && !this.window.isDestroyed()) {
|
||||
this.window.minimize()
|
||||
}
|
||||
} catch {
|
||||
// Resolving interaction must not depend on native minimize success.
|
||||
}
|
||||
this.finishInteraction(frame)
|
||||
})()
|
||||
this.interactionClosing = operation
|
||||
void operation.finally(() => {
|
||||
if (this.interactionClosing === operation) {
|
||||
this.interactionClosing = undefined
|
||||
}
|
||||
})
|
||||
return operation
|
||||
}
|
||||
|
||||
private finishInteraction(frame?: BrowserScreenshot): void {
|
||||
const interaction = this.interaction
|
||||
this.interaction = undefined
|
||||
this.setParentEnabled(true)
|
||||
interaction?.resolve(frame)
|
||||
}
|
||||
|
||||
private setParentEnabled(enabled: boolean): void {
|
||||
try {
|
||||
if (
|
||||
!this.parentWindow ||
|
||||
this.parentWindow.isDestroyed?.() === true
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.parentWindow.setEnabled?.(enabled)
|
||||
if (enabled) {
|
||||
this.parentWindow.focus?.()
|
||||
}
|
||||
} catch {
|
||||
// Parent-window state must not break browser-session cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
@@ -686,7 +632,7 @@ export class ElectronBrowserSession {
|
||||
this.disposed = true
|
||||
this.approvedOrigin = undefined
|
||||
this.loadingListeners.clear()
|
||||
this.finishInteraction()
|
||||
this.navigationListeners.clear()
|
||||
for (const { target, event, listener } of this.listeners.splice(0)) {
|
||||
target.off(event, listener)
|
||||
}
|
||||
@@ -694,10 +640,15 @@ export class ElectronBrowserSession {
|
||||
this.webContents.debugger.detach()
|
||||
}
|
||||
this.webContents.stop()
|
||||
if (!this.window.isDestroyed()) {
|
||||
this.window.destroy()
|
||||
} else if (!this.webContents.isDestroyed()) {
|
||||
this.webContents.destroy()
|
||||
this.view.setVisible(false)
|
||||
if (this.view.nativeView) {
|
||||
this.parentWindow?.contentView?.removeChildView(this.view.nativeView)
|
||||
}
|
||||
if (!this.webContents.isDestroyed()) {
|
||||
this.webContents.close?.({ waitForBeforeUnload: false })
|
||||
if (!this.webContents.isDestroyed()) {
|
||||
this.webContents.destroy()
|
||||
}
|
||||
}
|
||||
await cleanupIsolatedState(
|
||||
this.partitionSession,
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('ExecutionSpaceResolver', () => {
|
||||
|
||||
it('binds remote access only to the configured Host and path', () => {
|
||||
const workspaceAccess = {
|
||||
manage: vi.fn(),
|
||||
getIdentity: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
|
||||
+17
-11
@@ -429,7 +429,6 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
}))
|
||||
}
|
||||
const onRuntimeSettingsChanged = vi.fn(async () => {})
|
||||
const interact = vi.fn(async () => {})
|
||||
const navigate = vi.fn(async () => ({
|
||||
url: 'https://example.com/',
|
||||
origin: 'https://example.com'
|
||||
@@ -443,6 +442,7 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
origin: 'https://example.com'
|
||||
}))
|
||||
const stopLoading = vi.fn(async () => true)
|
||||
const setViewport = vi.fn()
|
||||
const releaseConversation = vi.fn(async () => {})
|
||||
const selectFiles = vi.fn(
|
||||
async (
|
||||
@@ -509,7 +509,7 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
back,
|
||||
reload,
|
||||
stopLoading,
|
||||
interact,
|
||||
setViewport,
|
||||
releaseConversation,
|
||||
onState: (listener) => {
|
||||
browserStateListener = listener
|
||||
@@ -741,16 +741,22 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
expect(stopLoading).toHaveBeenCalledWith('browser-conversation')
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.browserInteract)?.(event, {
|
||||
conversationId: 'browser-conversation'
|
||||
expect(
|
||||
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
|
||||
conversationId: 'browser-conversation',
|
||||
bounds: { x: 900, y: 120, width: 320, height: 600 }
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
expect(interact).toHaveBeenCalledWith(
|
||||
'browser-conversation',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
|
||||
).toBeUndefined()
|
||||
expect(setViewport).toHaveBeenCalledWith('browser-conversation', {
|
||||
x: 900,
|
||||
y: 120,
|
||||
width: 320,
|
||||
height: 600
|
||||
})
|
||||
expect(
|
||||
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {})
|
||||
).toBeUndefined()
|
||||
expect(setViewport).toHaveBeenLastCalledWith()
|
||||
navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError())
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
||||
|
||||
+36
-18
@@ -27,9 +27,9 @@ import {
|
||||
agentQuestionResponseSchema,
|
||||
agentRequestSchema,
|
||||
browserBackRequestSchema,
|
||||
browserInteractRequestSchema,
|
||||
browserNavigateRequestSchema,
|
||||
browserReloadRequestSchema,
|
||||
browserSetViewportRequestSchema,
|
||||
browserStopLoadingRequestSchema,
|
||||
browserStopRequestSchema,
|
||||
clipboardTextSchema,
|
||||
@@ -1100,10 +1100,10 @@ export function registerIpcHandlers(
|
||||
signal: AbortSignal
|
||||
): Promise<{ url: string; origin: string }>
|
||||
stopLoading(conversationId: string): boolean | Promise<boolean>
|
||||
interact(
|
||||
conversationId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void>
|
||||
setViewport(
|
||||
conversationId?: string,
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
): void
|
||||
releaseConversation(conversationId: string): Promise<void>
|
||||
onState(listener: (state: BrowserLiveState) => void): () => void
|
||||
},
|
||||
@@ -3644,19 +3644,6 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.browserInteract,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const request = browserInteractRequestSchema.parse(input)
|
||||
const control = await requireBrowserControl()
|
||||
await control.interact(
|
||||
request.conversationId,
|
||||
new AbortController().signal
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.agentRun, async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (executionPaused || shuttingDown) {
|
||||
@@ -6595,6 +6582,19 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.workspaceManage, async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { workspaceManagementRequestSchema, workspaceManagementResultSchema } = await import('../shared/workspace-management-contracts')
|
||||
const value = workspaceManagementRequestSchema.parse(input)
|
||||
const project = assistantDatabase.getProject(value.projectId)
|
||||
if (project.executionSpace?.kind === 'ssh') await requireRemoteProjectsEnabled()
|
||||
const executionSpace = spaceResolver.resolveProject(project)
|
||||
try {
|
||||
return workspaceManagementResultSchema.parse(await executionSpace.workspaceAccess.manage(value.action))
|
||||
} finally {
|
||||
await executionSpace.workspaceAccess.dispose()
|
||||
}
|
||||
})
|
||||
registerHandler(
|
||||
ipcChannels.workspaceChangesGet,
|
||||
async (event, input: unknown) => {
|
||||
@@ -6632,6 +6632,24 @@ export function registerIpcHandlers(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.browserSetViewport,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const request = browserSetViewportRequestSchema.parse(input)
|
||||
if (!request.conversationId || !request.bounds) {
|
||||
browserControl?.setViewport()
|
||||
return
|
||||
}
|
||||
browserControl?.setViewport(request.conversationId, {
|
||||
x: request.bounds.x,
|
||||
y: request.bounds.y,
|
||||
width: request.bounds.width,
|
||||
height: request.bounds.height
|
||||
})
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.workspaceDirectoryList,
|
||||
async (event, input: unknown) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import { workspaceManagementResultSchema } from '../../shared/workspace-management-contracts'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
AGENT_PROTOCOL_VERSION,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
remoteSemanticTranscriptAckResultSchema,
|
||||
remoteSemanticTranscriptPageRequestSchema,
|
||||
remoteSemanticTranscriptPageResultSchema,
|
||||
remoteWorkspaceManageRequestSchema,
|
||||
remoteGitDiffRequestSchema,
|
||||
remoteGitDiffResultSchema,
|
||||
remoteGitStatusRequestSchema,
|
||||
@@ -149,6 +151,10 @@ export const AGENT_PROTOCOL_METHODS = {
|
||||
params: remoteGitStatusRequestSchema,
|
||||
result: remoteGitStatusResultSchema
|
||||
},
|
||||
'workspace/manage': {
|
||||
params: remoteWorkspaceManageRequestSchema,
|
||||
result: workspaceManagementResultSchema
|
||||
},
|
||||
'git/diff': {
|
||||
params: remoteGitDiffRequestSchema,
|
||||
result: remoteGitDiffResultSchema
|
||||
|
||||
@@ -89,6 +89,7 @@ function descriptor(): SshExecutionSpaceDescriptor {
|
||||
cacheIdentity: 'cache-1',
|
||||
routeIdentity: 'route-1',
|
||||
workspaceAccess: {
|
||||
manage: vi.fn(),
|
||||
getIdentity: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
|
||||
@@ -247,6 +247,8 @@ function createLease(state: LeaseState): RemoteWorkspaceTransportLease {
|
||||
await request('git/status', value, signal),
|
||||
getGitDiff: async (value, signal) =>
|
||||
await request('git/diff', value, signal),
|
||||
manageWorkspace: async (value, signal) =>
|
||||
await request('workspace/manage', value, signal),
|
||||
release: () => {
|
||||
if (released) {
|
||||
return
|
||||
@@ -282,6 +284,7 @@ function releaseLeaseResources(
|
||||
}
|
||||
|
||||
type WorkspaceReadProtocolMethod =
|
||||
| 'workspace/manage'
|
||||
| 'workspace/validate'
|
||||
| 'workspace/close'
|
||||
| 'workspace/list'
|
||||
|
||||
@@ -399,6 +399,10 @@ function formatChangedFiles(files: WorkspaceChangedFile[]): string {
|
||||
}
|
||||
|
||||
export class LocalWorkspaceAccess implements WorkspaceAccess {
|
||||
async manage(action: import('../../shared/workspace-management-contracts').WorkspaceManagementAction, signal?: AbortSignal): Promise<import('../../shared/workspace-management-contracts').WorkspaceManagementResult> {
|
||||
const { manageWorkspace } = await import('./workspace-management')
|
||||
return manageWorkspace(await this.getWorkspace(), action, signal)
|
||||
}
|
||||
private canonicalWorkspace?: Promise<string>
|
||||
|
||||
constructor(private readonly rootPath: string) {}
|
||||
|
||||
@@ -36,6 +36,7 @@ function createLease(
|
||||
overrides: Partial<RemoteWorkspaceTransportLease> = {}
|
||||
): RemoteWorkspaceTransportLease {
|
||||
return {
|
||||
manageWorkspace: vi.fn(),
|
||||
binding: {
|
||||
hostId: binding.hostId,
|
||||
hostRevision: 2,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES,
|
||||
REMOTE_WORKSPACE_LIMITS,
|
||||
remoteWorkspaceManageRequestSchema,
|
||||
remoteGitDiffRequestSchema,
|
||||
remoteGitDiffResultSchema,
|
||||
remoteGitStatusRequestSchema,
|
||||
@@ -80,6 +81,7 @@ export type RemoteWorkspaceTransportBinding = {
|
||||
* exposes neither an SSH connection nor a generic JSON-RPC method.
|
||||
*/
|
||||
export interface RemoteWorkspaceTransportLease {
|
||||
manageWorkspace(request: import('zod').infer<typeof remoteWorkspaceManageRequestSchema>, signal?: AbortSignal): Promise<import('../../shared/workspace-management-contracts').WorkspaceManagementResult>
|
||||
readonly binding: RemoteWorkspaceTransportBinding
|
||||
validateWorkspace(
|
||||
request: RemoteWorkspaceValidateRequest,
|
||||
@@ -266,6 +268,16 @@ function formatChangedFiles(files: WorkspaceChangedFile[]): string {
|
||||
}
|
||||
|
||||
export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
async manage(action: import('../../shared/workspace-management-contracts').WorkspaceManagementAction, signal?: AbortSignal): Promise<import('../../shared/workspace-management-contracts').WorkspaceManagementResult> {
|
||||
const opened = await this.open(signal)
|
||||
const validation = remoteWorkspaceValidateResultSchema.parse(await opened.lease.validateWorkspace({ remoteRootPath: this.binding.remoteRootPath, requestedAccess: 'read-write', requiredCapabilities: [] }, signal))
|
||||
const handle = validation.handle
|
||||
try {
|
||||
return await opened.lease.manageWorkspace(remoteWorkspaceManageRequestSchema.parse({ workspaceId: handle.workspaceId, generation: handle.generation, action }), signal)
|
||||
} finally {
|
||||
await opened.lease.closeWorkspace({ workspaceId: handle.workspaceId, generation: handle.generation })
|
||||
}
|
||||
}
|
||||
private opening?: Promise<OpenRemoteWorkspace>
|
||||
private opened?: OpenRemoteWorkspace
|
||||
private disposed = false
|
||||
|
||||
@@ -27,6 +27,8 @@ implements WorkspaceAccess {
|
||||
throw new Error(this.message)
|
||||
}
|
||||
|
||||
async manage(): Promise<never> { return this.unsupported() }
|
||||
|
||||
async getIdentity(): Promise<WorkspaceIdentity> {
|
||||
return this.unsupported()
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export type WorkspaceChangesInput = {
|
||||
export type { WorkspaceChanges, WorkspaceChangedFile }
|
||||
|
||||
export interface WorkspaceAccess {
|
||||
manage(action: import('../../shared/workspace-management-contracts').WorkspaceManagementAction, signal?: AbortSignal): Promise<import('../../shared/workspace-management-contracts').WorkspaceManagementResult>
|
||||
getIdentity(): Promise<WorkspaceIdentity>
|
||||
listDirectory(input: WorkspaceDirectoryInput): Promise<DirectoryPage>
|
||||
stat(input: WorkspacePathInput): Promise<WorkspaceEntry>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { manageWorkspace } from './workspace-management'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
|
||||
async function repository(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-workspace-management-'))
|
||||
roots.push(root)
|
||||
git(root, 'init', '-b', 'main')
|
||||
return root
|
||||
}
|
||||
function git(root: string, ...args: string[]): string {
|
||||
return execFileSync('git', ['-C', root, '-c', 'user.name=Workspace Test', '-c', 'user.email=workspace@example.invalid', ...args], { encoding: 'utf8', windowsHide: true }).trim()
|
||||
}
|
||||
it('creates, moves, inspects and deletes without overwriting an existing file', async () => {
|
||||
const root = await repository()
|
||||
await manageWorkspace(root, { kind: 'createDirectory', path: 'docs' })
|
||||
await manageWorkspace(root, { kind: 'createFile', path: 'docs/test.txt' })
|
||||
await writeFile(join(root, 'docs/test.txt'), 'keep')
|
||||
await expect(manageWorkspace(root, { kind: 'createFile', path: 'docs/test.txt' })).rejects.toThrow()
|
||||
await manageWorkspace(root, { kind: 'move', path: 'docs/test.txt', destination: 'docs/new.txt' })
|
||||
expect(await readFile(join(root, 'docs/new.txt'), 'utf8')).toBe('keep')
|
||||
await expect(manageWorkspace(root, { kind: 'properties', path: 'docs/new.txt' })).resolves.toMatchObject({ kind: 'properties', size: 4, type: 'file' })
|
||||
await expect(manageWorkspace(root, { kind: 'delete', path: '../outside' })).rejects.toThrow()
|
||||
await manageWorkspace(root, { kind: 'delete', path: 'docs' })
|
||||
})
|
||||
it('reads unborn history, initial commit files and literal paths, and switches with dirty files intact', async () => {
|
||||
const root = await repository()
|
||||
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ commits: [] })
|
||||
await writeFile(join(root, '[test].txt'), 'initial\n')
|
||||
git(root, 'add', '.')
|
||||
git(root, 'commit', '-m', 'Initial subject')
|
||||
const oid = git(root, 'rev-parse', 'HEAD')
|
||||
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: 'Workspace Test' }] })
|
||||
await expect(manageWorkspace(root, { kind: 'commitFiles', oid })).resolves.toMatchObject({ files: [{ path: '[test].txt', status: 'A' }] })
|
||||
await expect(manageWorkspace(root, { kind: 'commitDiff', oid, path: '[test].txt' })).resolves.toMatchObject({ patch: expect.stringContaining('+initial') })
|
||||
await writeFile(join(root, '[test].txt'), 'dirty\n')
|
||||
await manageWorkspace(root, { kind: 'createBranch', branch: 'feature/test' })
|
||||
await manageWorkspace(root, { kind: 'switchBranch', branch: 'main', remote: false })
|
||||
expect(await readFile(join(root, '[test].txt'), 'utf8')).toBe('dirty\n')
|
||||
expect(git(root, 'stash', 'list')).toBe('')
|
||||
await expect(manageWorkspace(root, { kind: 'branches' })).resolves.toMatchObject({ current: 'main', branches: expect.arrayContaining([{ name: 'feature/test', remote: false }]) })
|
||||
})
|
||||
it('reports switch conflicts without discarding dirty content and pages stable history', async () => {
|
||||
const root = await repository()
|
||||
await writeFile(join(root, 'test.txt'), 'initial')
|
||||
git(root, 'add', '.'); git(root, 'commit', '-m', 'initial')
|
||||
git(root, 'switch', '-c', 'other')
|
||||
await writeFile(join(root, 'test.txt'), 'other')
|
||||
git(root, 'commit', '-am', 'other')
|
||||
git(root, 'switch', 'main')
|
||||
await writeFile(join(root, 'test.txt'), 'dirty')
|
||||
await expect(manageWorkspace(root, { kind: 'switchBranch', branch: 'other', remote: false })).rejects.toThrow(/overwritten/)
|
||||
expect(await readFile(join(root, 'test.txt'), 'utf8')).toBe('dirty')
|
||||
for (let index = 0; index < 51; index++) git(root, 'commit', '--allow-empty', '-m', `page ${index}`)
|
||||
const first = await manageWorkspace(root, { kind: 'history', offset: 0 })
|
||||
expect(first.kind).toBe('history')
|
||||
if (first.kind !== 'history') return
|
||||
expect(first.commits).toHaveLength(50)
|
||||
expect(first.hasMore).toBe(true)
|
||||
const second = await manageWorkspace(root, { kind: 'history', offset: 50, head: first.head })
|
||||
expect(second).toMatchObject({ hasMore: false, commits: [{ subject: 'page 0' }, { subject: 'initial' }] })
|
||||
}, 30_000)
|
||||
@@ -0,0 +1,107 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { workspaceManagementActionSchema, type WorkspaceManagementAction, type WorkspaceManagementResult } from '../../shared/workspace-management-contracts'
|
||||
import { getCanonicalWorkspace, isPathInside } from '../workspace-file-access'
|
||||
|
||||
// Shared by the local workspace and the Agent after resolving its workspace handle.
|
||||
export async function manageWorkspace(rootPath: string, input: WorkspaceManagementAction, signal?: AbortSignal): Promise<WorkspaceManagementResult> {
|
||||
const action = workspaceManagementActionSchema.parse(input)
|
||||
signal?.throwIfAborted()
|
||||
const root = await getCanonicalWorkspace(rootPath)
|
||||
const git = (args: string[]): Promise<string> => new Promise((resolveOutput, reject) => {
|
||||
execFile('git', ['--no-pager', '--literal-pathspecs', '-C', root, ...args], {
|
||||
encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 4 * 1024 * 1024,
|
||||
signal, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_PAGER: 'cat' }
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error) reject(new Error(stderr.trim() || error.message))
|
||||
else resolveOutput(stdout)
|
||||
})
|
||||
})
|
||||
if (['createFile', 'createDirectory', 'move', 'delete', 'properties'].includes(action.kind) && 'path' in action) {
|
||||
const target = resolve(root, action.path)
|
||||
const parent = await realpath(dirname(target))
|
||||
if (target === root || !isPathInside(root, target) || !isPathInside(root, parent)) throw new Error('Path is outside the workspace')
|
||||
if (action.kind === 'createFile') {
|
||||
const handle = await open(target, 'wx')
|
||||
await handle.close()
|
||||
} else if (action.kind === 'createDirectory') {
|
||||
await mkdir(target)
|
||||
} else {
|
||||
const metadata = await lstat(target)
|
||||
if (metadata.isSymbolicLink()) throw new Error('Cannot modify a symbolic link through workspace management')
|
||||
if (action.kind === 'properties') return { kind: 'properties', path: target, type: metadata.isDirectory() ? 'directory' : metadata.isFile() ? 'file' : 'other', size: metadata.size, modifiedAt: metadata.mtime.toISOString() }
|
||||
if (action.kind === 'delete') await rm(target, { recursive: metadata.isDirectory() })
|
||||
if (action.kind === 'move') {
|
||||
const destination = resolve(root, action.destination)
|
||||
const destinationParent = await realpath(dirname(destination))
|
||||
if (!isPathInside(root, destinationParent) || !isPathInside(root, destination) || destination === root) throw new Error('Destination is outside the workspace')
|
||||
try { await lstat(destination) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
signal?.throwIfAborted()
|
||||
await rename(target, destination)
|
||||
return { kind: 'done' }
|
||||
}
|
||||
throw new Error('Destination already exists')
|
||||
}
|
||||
}
|
||||
return { kind: 'done' }
|
||||
}
|
||||
switch (action.kind) {
|
||||
case 'branches': {
|
||||
const current = (await git(['branch', '--show-current'])).trim()
|
||||
const output = await git(['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/heads', 'refs/remotes'])
|
||||
return { kind: 'branches', current, branches: output.trim().split('\n').filter(Boolean).flatMap((line) => {
|
||||
const [ref, symbolic] = line.split('\0')
|
||||
if (!ref || symbolic) return []
|
||||
return [{ name: ref.replace(/^refs\/(heads|remotes)\//, ''), remote: ref.startsWith('refs/remotes/') }]
|
||||
}) }
|
||||
}
|
||||
case 'createBranch':
|
||||
await git(['check-ref-format', '--branch', action.branch])
|
||||
await git(['switch', '-c', action.branch])
|
||||
return { kind: 'done' }
|
||||
case 'switchBranch':
|
||||
await git(['check-ref-format', '--branch', action.branch])
|
||||
await git(action.remote ? ['switch', '--track', action.branch] : ['switch', action.branch])
|
||||
return { kind: 'done' }
|
||||
case 'fetch':
|
||||
await git(['fetch', '--all'])
|
||||
return { kind: 'done' }
|
||||
case 'history': {
|
||||
let head = action.head
|
||||
if (!head) {
|
||||
const refs = (await git(['rev-parse', '--verify', '--quiet', 'HEAD']).catch(async (error: unknown) => {
|
||||
// An unborn branch has no commits; other repository errors remain visible.
|
||||
await git(['symbolic-ref', 'HEAD'])
|
||||
if ((await git(['rev-list', '--all', '--max-count=1'])).trim()) throw error
|
||||
return ''
|
||||
})).trim()
|
||||
if (!refs) return { kind: 'history', commits: [], hasMore: false }
|
||||
head = refs
|
||||
}
|
||||
const output = await git(['log', head, `--skip=${action.offset}`, '-n', '51', '--format=%H%x00%s%x00%an%x00%aI%x00%D%x00'])
|
||||
const fields = output.split('\0')
|
||||
const commits: Extract<WorkspaceManagementResult, { kind: 'history' }>['commits'] = []
|
||||
for (let index = 0; index + 4 < fields.length; index += 5) commits.push({ oid: fields[index]!.trim(), subject: fields[index + 1]!, author: fields[index + 2]!, time: fields[index + 3]!, refs: fields[index + 4]! })
|
||||
return { kind: 'history', head, commits: commits.slice(0, 50), hasMore: commits.length > 50 }
|
||||
}
|
||||
case 'commitFiles': {
|
||||
const output = await git(['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-m', '--first-parent', '-M', '-z', action.oid])
|
||||
const fields = output.split('\0')
|
||||
const files: Extract<WorkspaceManagementResult, { kind: 'commitFiles' }>['files'] = []
|
||||
for (let index = 0; index + 1 < fields.length;) {
|
||||
const status = fields[index++]!
|
||||
const first = fields[index++]!
|
||||
const renamed = /^[RC]/.test(status)
|
||||
files.push({ status, path: renamed ? fields[index++]! : first, ...(renamed ? { previousPath: first } : {}) })
|
||||
}
|
||||
return { kind: 'commitFiles', files }
|
||||
}
|
||||
case 'commitDiff': {
|
||||
const patch = await git(['show', '--format=', '--first-parent', '--diff-merges=first-parent', '--no-ext-diff', '--no-textconv', action.oid, '--', action.path])
|
||||
return { kind: 'commitDiff', patch: patch.slice(0, 256 * 1024), truncated: patch.length > 256 * 1024 }
|
||||
}
|
||||
default: throw new Error('Unsupported workspace action')
|
||||
}
|
||||
}
|
||||
@@ -328,11 +328,11 @@ const desktopApi: DesktopApi = {
|
||||
conversationId
|
||||
})
|
||||
},
|
||||
interact: async (conversationId: string) => {
|
||||
await ipcRenderer.invoke(
|
||||
ipcChannels.browserInteract,
|
||||
{ conversationId }
|
||||
)
|
||||
setViewport: async (conversationId, bounds) => {
|
||||
await ipcRenderer.invoke(ipcChannels.browserSetViewport, {
|
||||
...(conversationId ? { conversationId } : {}),
|
||||
...(bounds ? { bounds } : {})
|
||||
})
|
||||
},
|
||||
stop: async (conversationId: string) => {
|
||||
await ipcRenderer.invoke(
|
||||
@@ -1089,6 +1089,7 @@ const desktopApi: DesktopApi = {
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
manage: (projectId, action) => ipcRenderer.invoke(ipcChannels.workspaceManage, { projectId, action }),
|
||||
getFileDiff: (projectId: string, path: string) =>
|
||||
ipcRenderer.invoke(ipcChannels.workspaceFileDiff, {
|
||||
projectId,
|
||||
|
||||
@@ -250,7 +250,7 @@ const api: DesktopApi = {
|
||||
back: vi.fn(async () => {}),
|
||||
reload: vi.fn(async () => {}),
|
||||
stopLoading: vi.fn(async () => {}),
|
||||
interact: vi.fn(async () => {}),
|
||||
setViewport: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
onState: vi.fn((listener) => {
|
||||
browserListener = listener;
|
||||
@@ -508,6 +508,7 @@ const api: DesktopApi = {
|
||||
}),
|
||||
},
|
||||
workspace: {
|
||||
manage: vi.fn(async () => ({ kind: 'branches' as const, current: 'main', branches: [] })),
|
||||
getFileDiff: vi.fn(),
|
||||
getChanges: vi.fn(async () => ({
|
||||
rootPath: "C:\\Workspace",
|
||||
@@ -5469,6 +5470,14 @@ describe("App", () => {
|
||||
"README.md",
|
||||
0,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "复制相对路径" }));
|
||||
await waitFor(() =>
|
||||
expect(api.clipboard.writeText).toHaveBeenCalledWith("README.md"),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "用默认应用打开" }));
|
||||
await waitFor(() =>
|
||||
expect(api.workspace.openPath).toHaveBeenCalledWith(projectId, "README.md", "file"),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "返回工作区" }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "README.md" }),
|
||||
@@ -5515,16 +5524,10 @@ describe("App", () => {
|
||||
|
||||
fireEvent.click(screen.getByLabelText("切换助手工作栏"));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "工作区" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "在系统资源管理器中打开文件夹 docs",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "使用默认应用打开文件 README.md",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(await screen.findByLabelText("docs 的更多操作"));
|
||||
fireEvent.click(within(screen.getByRole("menu", { name: "docs" })).getByRole("menuitem", { name: "使用默认应用打开" }));
|
||||
fireEvent.click(screen.getByLabelText("README.md 的更多操作"));
|
||||
fireEvent.click(within(screen.getByRole("menu", { name: "README.md" })).getByRole("menuitem", { name: "使用默认应用打开" }));
|
||||
await waitFor(() =>
|
||||
expect(api.workspace.openPath).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
@@ -9960,14 +9963,26 @@ describe("App", () => {
|
||||
screen.queryByRole("heading", { name: "项目工作区" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "刷新工作区文件" }),
|
||||
await screen.findByRole("button", { name: "刷新工作区文件" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/选择文件后在当前工作区内预览/),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "浏览器" }));
|
||||
expect(screen.getByText("实时浏览器")).toBeInTheDocument();
|
||||
expect(screen.queryByText("实时浏览器")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Agent 打开网页后/)).toBeInTheDocument();
|
||||
const fullscreen = screen.getByRole("button", {
|
||||
name: "全屏显示浏览器",
|
||||
});
|
||||
fireEvent.click(fullscreen);
|
||||
expect(sidebar).toHaveClass("assistant-sidebar--browser-fullscreen");
|
||||
const exitFullscreen = screen.getByRole("button", {
|
||||
name: "退出浏览器全屏",
|
||||
});
|
||||
expect(exitFullscreen).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(exitFullscreen);
|
||||
expect(sidebar).not.toHaveClass("assistant-sidebar--browser-fullscreen");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "成果" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "导入 PDF、图片或网页" }),
|
||||
@@ -10044,11 +10059,11 @@ describe("App", () => {
|
||||
within(toolbar).getByRole("button", { name: "刷新" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "交互" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "交互" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "关闭浏览器" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "关闭浏览器" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(go).toBeDisabled();
|
||||
expect(screen.getByText(/输入地址并前往可打开页面/u)).toBeInTheDocument();
|
||||
|
||||
@@ -10143,11 +10158,11 @@ describe("App", () => {
|
||||
expect(address).toBeDisabled();
|
||||
expect(go).toBeDisabled();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "交互" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "交互" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "关闭浏览器" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "关闭浏览器" }),
|
||||
).not.toBeInTheDocument();
|
||||
const stopLoading = within(toolbar).getByRole("button", {
|
||||
name: "停止加载",
|
||||
});
|
||||
@@ -10213,10 +10228,7 @@ describe("App", () => {
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByAltText("Agent 实时浏览器画面")).toHaveAttribute(
|
||||
"src",
|
||||
"data:image/jpeg;base64,/9j/2Q==",
|
||||
);
|
||||
expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument();
|
||||
const toolbar = screen.getByRole("form", {
|
||||
name: "浏览器工具栏",
|
||||
});
|
||||
@@ -10241,10 +10253,7 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
expect(address).toHaveValue("https://typed.example/draft");
|
||||
expect(screen.getByAltText("Agent 实时浏览器画面")).toHaveAttribute(
|
||||
"src",
|
||||
"data:image/jpeg;base64,/9j/2Q==",
|
||||
);
|
||||
expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(address, { key: "Escape" });
|
||||
expect(address).toHaveValue("https://example.com/redirected");
|
||||
@@ -10292,10 +10301,7 @@ describe("App", () => {
|
||||
),
|
||||
);
|
||||
expect(address).toHaveValue("https://example.com/canonical/");
|
||||
fireEvent.click(within(toolbar).getByRole("button", { name: "交互" }));
|
||||
await waitFor(() =>
|
||||
expect(api.browser.interact).toHaveBeenCalledWith(conversationId),
|
||||
);
|
||||
expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
browserListener?.(
|
||||
@@ -10314,11 +10320,11 @@ describe("App", () => {
|
||||
).toBeDisabled();
|
||||
expect(address).toBeDisabled();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "交互" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "交互" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "关闭浏览器" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "关闭浏览器" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
browserListener?.(
|
||||
@@ -10339,11 +10345,11 @@ describe("App", () => {
|
||||
within(toolbar).getByRole("button", { name: "前往" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "交互" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "交互" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "关闭浏览器" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "关闭浏览器" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
browserListener?.(
|
||||
@@ -10364,8 +10370,8 @@ describe("App", () => {
|
||||
});
|
||||
expect(stopLoading).toBeEnabled();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "关闭浏览器" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "关闭浏览器" }),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(stopLoading);
|
||||
await waitFor(() =>
|
||||
expect(api.browser.stopLoading).toHaveBeenCalledWith(conversationId),
|
||||
@@ -10387,11 +10393,11 @@ describe("App", () => {
|
||||
).toBeDisabled();
|
||||
expect(address).toBeDisabled();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "交互" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "交互" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(toolbar).getByRole("button", { name: "关闭浏览器" }),
|
||||
).toBeDisabled();
|
||||
within(toolbar).queryByRole("button", { name: "关闭浏览器" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
browserListener?.(
|
||||
@@ -10402,12 +10408,6 @@ describe("App", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole("button", { name: "关闭浏览器" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.browser.stop).toHaveBeenCalledWith(conversationId),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens Smart Heartbeat as a first-class workspace", async () => {
|
||||
|
||||
@@ -4050,7 +4050,9 @@ function App(): React.JSX.Element {
|
||||
workspaceChangesRequestRef.current === requestId &&
|
||||
activeProjectIdRef.current === projectId
|
||||
) {
|
||||
setWorkspaceChanges(changes);
|
||||
setWorkspaceChanges((current) => changes.error && current?.available
|
||||
? { ...current, error: changes.error }
|
||||
: changes);
|
||||
}
|
||||
},
|
||||
[],
|
||||
@@ -10746,11 +10748,6 @@ function App(): React.JSX.Element {
|
||||
browserApi.back(conversationId),
|
||||
)
|
||||
}
|
||||
onInteractBrowser={() =>
|
||||
runBrowserCommand((browserApi, conversationId) =>
|
||||
browserApi.interact(conversationId),
|
||||
)
|
||||
}
|
||||
onNavigateBrowser={(url) =>
|
||||
runBrowserCommand((browserApi, conversationId) =>
|
||||
browserApi.navigate(conversationId, url),
|
||||
@@ -10766,11 +10763,6 @@ function App(): React.JSX.Element {
|
||||
browserApi.stopLoading(conversationId),
|
||||
)
|
||||
}
|
||||
onStopBrowser={() =>
|
||||
runBrowserCommand((browserApi, conversationId) =>
|
||||
browserApi.stop(conversationId),
|
||||
)
|
||||
}
|
||||
onImportArtifacts={async () => {
|
||||
const imported = await window.goodbuddy.artifacts.importFiles(
|
||||
activeProjectId || undefined,
|
||||
|
||||
@@ -78,14 +78,12 @@ function renderSidebar({
|
||||
onLoadWorkspaceFile={onLoadWorkspaceFile}
|
||||
onLoadWorkspaceDiff={vi.fn()}
|
||||
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
|
||||
onInteractBrowser={vi.fn(async () => undefined)}
|
||||
onRefreshChanges={vi.fn(async () => undefined)}
|
||||
onRemoveSchedule={vi.fn(async () => undefined)}
|
||||
onRespondApproval={vi.fn()}
|
||||
onRunSchedule={vi.fn(async () => undefined)}
|
||||
onSetScheduleEnabled={vi.fn(async () => undefined)}
|
||||
onOpenTask={vi.fn()}
|
||||
onStopBrowser={vi.fn(async () => undefined)}
|
||||
onTabChange={vi.fn()}
|
||||
open
|
||||
restoreFocusRef={restoreFocusRef}
|
||||
@@ -101,7 +99,7 @@ function renderSidebar({
|
||||
}
|
||||
|
||||
describe('RightAssistantSidebar resizing', () => {
|
||||
it('resizes with pointer capture and preserves equal pane minima', () => {
|
||||
it('resizes with pointer capture and preserves compact pane minima', () => {
|
||||
const sidebar = renderSidebar()
|
||||
const separator = screen.getByRole('separator', {
|
||||
name: '调整助手工作栏宽度'
|
||||
@@ -128,9 +126,9 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(sidebar).toHaveClass('assistant-sidebar--resizing')
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('1100px')
|
||||
expect(separator).toHaveAttribute('aria-valuemin', '300')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '1100')
|
||||
).toBe('1240px')
|
||||
expect(separator).toHaveAttribute('aria-valuemin', '160')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '1240')
|
||||
|
||||
fireEvent.pointerUp(separator, { pointerId: 7 })
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(7)
|
||||
@@ -152,12 +150,12 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
fireEvent.keyDown(separator, { key: 'Home' })
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('300px')
|
||||
).toBe('160px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('1100px')
|
||||
).toBe('1240px')
|
||||
})
|
||||
|
||||
it('remains resizable when the sidebar docks in a medium window', () => {
|
||||
@@ -191,7 +189,7 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
).toBe('424px')
|
||||
})
|
||||
|
||||
it('keeps the equal minimum dock resizable in a narrow window', () => {
|
||||
it('keeps the compact dock resizable in a narrow window', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 680
|
||||
@@ -203,16 +201,16 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('300px')
|
||||
).toBe('204px')
|
||||
expect(sidebar).not.toHaveAttribute('aria-modal')
|
||||
expect(separator).toHaveAttribute('tabindex', '0')
|
||||
expect(separator).toHaveAttribute('aria-valuemin', '300')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '380')
|
||||
expect(separator).toHaveAttribute('aria-valuemin', '160')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '520')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('380px')
|
||||
).toBe('520px')
|
||||
})
|
||||
|
||||
it('excludes the primary sidebar from the equal pane limits', async () => {
|
||||
@@ -242,13 +240,13 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
name: '调整助手工作栏宽度'
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '600')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '740')
|
||||
)
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('600px')
|
||||
).toBe('740px')
|
||||
} finally {
|
||||
getBoundingClientRect.mockRestore()
|
||||
}
|
||||
@@ -265,6 +263,32 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the browser panel for only the toolbar and page viewport', () => {
|
||||
const sidebar = renderSidebar({ tab: 'browser' })
|
||||
const viewport = screen.getByLabelText('浏览器页面')
|
||||
const browserPanel = viewport.parentElement
|
||||
|
||||
expect(browserPanel).toHaveClass('assistant-sidebar__browser')
|
||||
expect(browserPanel?.children).toHaveLength(2)
|
||||
expect(screen.queryByText('实时浏览器')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '全屏显示浏览器' })
|
||||
)
|
||||
expect(sidebar).toHaveClass('assistant-sidebar--browser-fullscreen')
|
||||
expect(
|
||||
screen.getByRole('button', { name: '退出浏览器全屏' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(
|
||||
screen.getByRole('separator', { name: '调整助手工作栏宽度' })
|
||||
).toHaveAttribute('aria-disabled', 'false')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(sidebar).not.toHaveClass(
|
||||
'assistant-sidebar--browser-fullscreen'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the docked sidebar non-modal', () => {
|
||||
const sidebar = renderSidebar()
|
||||
|
||||
@@ -369,7 +393,7 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
rejectPreview?.(new Error('临时网络错误'))
|
||||
const error = await screen.findByRole('alert')
|
||||
expect(error).toHaveTextContent('临时网络错误')
|
||||
expect(screen.getByText('README.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('README.md', { selector: 'strong' })).toBeVisible()
|
||||
fireEvent.click(
|
||||
within(error).getByRole('button', { name: '刷新' })
|
||||
)
|
||||
@@ -384,6 +408,91 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns to the expanded directory, selected file and scroll position without reloading', async () => {
|
||||
const onListWorkspaceDirectory = vi.fn(async (path: string) => ({
|
||||
path,
|
||||
entries: path === ''
|
||||
? [{ name: 'src', path: 'src', type: 'directory' as const }]
|
||||
: [{ name: 'nested.txt', path: 'src/nested.txt', type: 'file' as const }],
|
||||
truncated: false
|
||||
}))
|
||||
renderSidebar({
|
||||
tab: 'workspace',
|
||||
workspaceProjectId: 'project-1',
|
||||
onListWorkspaceDirectory,
|
||||
onLoadWorkspaceFile: vi.fn().mockResolvedValue({
|
||||
path: 'src/nested.txt', name: 'nested.txt', content: 'Nested content',
|
||||
mimeType: 'text/plain', size: 14, offsetBytes: 0, nextOffsetBytes: 14, truncated: false
|
||||
})
|
||||
})
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'src' }))
|
||||
const file = await screen.findByRole('button', { name: 'nested.txt' })
|
||||
const body = file.closest('.assistant-sidebar__body') as HTMLElement
|
||||
body.scrollTop = 240
|
||||
fireEvent.click(file)
|
||||
expect(await screen.findByText('Nested content')).toBeVisible()
|
||||
expect(file).not.toBeVisible()
|
||||
expect(body.scrollTop).toBe(0)
|
||||
body.scrollTop = 80
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回工作区' }))
|
||||
expect(screen.getByRole('button', { name: 'src' })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(file).toBeVisible()
|
||||
expect(file).toHaveAttribute('aria-current', 'true')
|
||||
expect(file).toHaveFocus()
|
||||
expect(body.scrollTop).toBe(240)
|
||||
expect(onListWorkspaceDirectory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps preview content and reading position when refreshing fails', async () => {
|
||||
const onLoadWorkspaceFile = vi.fn().mockResolvedValueOnce({
|
||||
path: 'README.md', name: 'README.md', content: '# Original heading',
|
||||
mimeType: 'text/markdown', size: 18, offsetBytes: 0, nextOffsetBytes: 18, truncated: false
|
||||
}).mockRejectedValueOnce(new Error('Refresh unavailable'))
|
||||
renderSidebar({
|
||||
tab: 'workspace', workspaceProjectId: 'project-1', onLoadWorkspaceFile,
|
||||
onListWorkspaceDirectory: vi.fn(async (path: string) => ({
|
||||
path, entries: [{ name: 'README.md', path: 'README.md', type: 'file' as const }], truncated: false
|
||||
}))
|
||||
})
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'README.md' }))
|
||||
const heading = await screen.findByRole('heading', { name: 'Original heading' })
|
||||
const body = heading.closest('.assistant-sidebar__body') as HTMLElement
|
||||
body.scrollTop = 120
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Refresh unavailable')
|
||||
expect(heading).toBeVisible()
|
||||
expect(body.scrollTop).toBe(120)
|
||||
fireEvent.click(screen.getByRole('button', { name: '源码' }))
|
||||
expect(screen.getByText('# Original heading')).toBeVisible()
|
||||
expect(screen.queryByRole('heading', { name: 'Original heading' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('discards an unfinished file preview when switching projects', async () => {
|
||||
let resolveRead: (value: WorkspaceFilePreview) => void = () => undefined
|
||||
const props = {
|
||||
open: true, tab: 'workspace' as const, approvals: [], artifacts: [], schedules: [], tasks: [],
|
||||
conversationTitles: new Map(), projectNames: new Map(),
|
||||
onCreateCustomTask: vi.fn(), onImportArtifacts: vi.fn(), onLoadArtifact: vi.fn(),
|
||||
onLoadWorkspaceFile: vi.fn(() => new Promise<WorkspaceFilePreview>((resolve) => { resolveRead = resolve })),
|
||||
onLoadWorkspaceDiff: vi.fn(), onOpenWorkspaceEntry: vi.fn(),
|
||||
onRefreshChanges: vi.fn(), onRemoveSchedule: vi.fn(), onRespondApproval: vi.fn(),
|
||||
onRunSchedule: vi.fn(), onSetScheduleEnabled: vi.fn(), onOpenTask: vi.fn(), onTabChange: vi.fn(),
|
||||
onListWorkspaceDirectory: vi.fn(async (path: string) => ({
|
||||
path, entries: [{ name: 'file.txt', path: 'file.txt', type: 'file' as const }], truncated: false
|
||||
}))
|
||||
}
|
||||
const { rerender } = render(<RightAssistantSidebar {...props} workspaceProjectId="A" />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'file.txt' }))
|
||||
expect(screen.getByRole('status', { name: '正在读取文件…' })).toBeVisible()
|
||||
rerender(<RightAssistantSidebar {...props} workspaceProjectId="B" />)
|
||||
resolveRead({ path: 'file.txt', name: 'file.txt', content: 'Stale preview', mimeType: 'text/plain', size: 13, offsetBytes: 0, nextOffsetBytes: 13, truncated: false })
|
||||
await screen.findByRole('button', { name: 'file.txt' })
|
||||
rerender(<RightAssistantSidebar {...props} workspaceProjectId="A" />)
|
||||
expect(await screen.findByRole('button', { name: 'file.txt' })).toBeVisible()
|
||||
expect(screen.queryByRole('status', { name: '正在读取文件…' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Stale preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads and appends the rest of a large workspace file', async () => {
|
||||
const onLoadWorkspaceFile = vi
|
||||
.fn()
|
||||
|
||||
@@ -4,22 +4,24 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
ClockFading,
|
||||
Monitor,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
Square,
|
||||
Upload,
|
||||
X
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
@@ -107,11 +109,9 @@ type RightAssistantSidebarProps = {
|
||||
currentProject?: AssistantProject
|
||||
restoreFocusRef?: { current: HTMLElement | null }
|
||||
onBackBrowser?: () => Promise<void>
|
||||
onInteractBrowser: () => Promise<void>
|
||||
onNavigateBrowser?: (url: string) => Promise<void>
|
||||
onReloadBrowser?: () => Promise<void>
|
||||
onStopLoadingBrowser?: () => Promise<void>
|
||||
onStopBrowser: () => Promise<void>
|
||||
onCreateCustomTask: () => void
|
||||
onImportArtifacts: () => Promise<void>
|
||||
onLoadArtifact: (artifactId: string) => Promise<void>
|
||||
@@ -150,7 +150,7 @@ const tabIds: AssistantSidebarTab[] = [
|
||||
]
|
||||
const emptyChangedFiles: WorkspaceChanges['files'] = []
|
||||
const defaultSidebarRatio = 0.3
|
||||
const minimumPaneWidth = 300
|
||||
const minimumPaneWidth = 160
|
||||
const keyboardResizeStep = 16
|
||||
const workbarStorageKey = 'goodbuddy.workbar-layout.v1'
|
||||
|
||||
@@ -255,20 +255,20 @@ function addDefaultBrowserProtocol(address: string): string {
|
||||
function BrowserToolbar({
|
||||
activeConversationId,
|
||||
browserState,
|
||||
fullscreen,
|
||||
onBack,
|
||||
onInteract,
|
||||
onToggleFullscreen,
|
||||
onNavigate,
|
||||
onReload,
|
||||
onStop,
|
||||
onStopLoading
|
||||
}: {
|
||||
activeConversationId?: string
|
||||
browserState?: BrowserLiveState
|
||||
fullscreen: boolean
|
||||
onBack: () => Promise<boolean>
|
||||
onInteract: () => Promise<boolean>
|
||||
onToggleFullscreen: () => void
|
||||
onNavigate: (url: string) => Promise<boolean>
|
||||
onReload: () => Promise<boolean>
|
||||
onStop: () => Promise<boolean>
|
||||
onStopLoading: () => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
@@ -308,15 +308,6 @@ function BrowserToolbar({
|
||||
operationBlocked ||
|
||||
!sessionActive ||
|
||||
isLoading
|
||||
const interactDisabled =
|
||||
operationBlocked ||
|
||||
!sessionActive ||
|
||||
isLoading ||
|
||||
(status !== 'ready' && status !== 'failed')
|
||||
const closeDisabled =
|
||||
operationBlocked ||
|
||||
!sessionActive ||
|
||||
isLoading
|
||||
const goDisabled =
|
||||
actionPending ||
|
||||
!activeConversationId ||
|
||||
@@ -449,31 +440,143 @@ function BrowserToolbar({
|
||||
<span>{t('sidebar.browser.toolbar.go')}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('sidebar.browser.interact')}
|
||||
className="secondary-button assistant-sidebar__browser-interact"
|
||||
disabled={interactDisabled}
|
||||
onClick={() => void runToolbarAction(onInteract)}
|
||||
title={t('sidebar.browser.interact')}
|
||||
aria-label={
|
||||
fullscreen
|
||||
? t('sidebar.browser.toolbar.exitFullscreen')
|
||||
: t('sidebar.browser.toolbar.fullscreen')
|
||||
}
|
||||
aria-pressed={fullscreen}
|
||||
className="secondary-button assistant-sidebar__browser-tool-button assistant-sidebar__browser-fullscreen-button"
|
||||
onClick={onToggleFullscreen}
|
||||
title={
|
||||
fullscreen
|
||||
? t('sidebar.browser.toolbar.exitFullscreen')
|
||||
: t('sidebar.browser.toolbar.fullscreen')
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={12} />
|
||||
<span>{t('sidebar.browser.interact')}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('sidebar.browser.close')}
|
||||
className="danger-ghost assistant-sidebar__browser-close"
|
||||
disabled={closeDisabled}
|
||||
onClick={() => void runToolbarAction(onStop)}
|
||||
title={t('sidebar.browser.close')}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={12} />
|
||||
<span>{t('sidebar.browser.close')}</span>
|
||||
{fullscreen ? (
|
||||
<Minimize2 aria-hidden="true" size={14} />
|
||||
) : (
|
||||
<Maximize2 aria-hidden="true" size={14} />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function BrowserViewport({
|
||||
browserState,
|
||||
conversationId,
|
||||
visible
|
||||
}: {
|
||||
browserState?: BrowserLiveState
|
||||
conversationId?: string
|
||||
visible: boolean
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const host = hostRef.current
|
||||
const browserApi = window.goodbuddy?.browser
|
||||
if (!host || !conversationId || !browserApi?.setViewport) {
|
||||
return
|
||||
}
|
||||
let animationFrame: number | undefined
|
||||
let lastRequest = ''
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
const panel = host.closest<HTMLElement>('[role="tabpanel"]')
|
||||
const syncViewport = (): void => {
|
||||
if (animationFrame !== undefined) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = undefined
|
||||
const rect = host.getBoundingClientRect()
|
||||
const isVisible =
|
||||
visible &&
|
||||
Boolean(conversationId) &&
|
||||
panel?.hidden !== true &&
|
||||
rect.width >= 1 &&
|
||||
rect.height >= 1
|
||||
const bounds = isVisible
|
||||
? {
|
||||
x: Math.max(0, Math.round(rect.left)),
|
||||
y: Math.max(0, Math.round(rect.top)),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
}
|
||||
: undefined
|
||||
const request = JSON.stringify({ conversationId, bounds })
|
||||
if (request === lastRequest) {
|
||||
return
|
||||
}
|
||||
lastRequest = request
|
||||
void browserApi
|
||||
.setViewport(isVisible ? conversationId : undefined, bounds)
|
||||
.catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const updateObservation = (): void => {
|
||||
if (panel?.hidden !== false) {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = undefined
|
||||
syncViewport()
|
||||
return
|
||||
}
|
||||
if (!resizeObserver && typeof ResizeObserver === 'function') {
|
||||
resizeObserver = new ResizeObserver(syncViewport)
|
||||
resizeObserver.observe(host)
|
||||
}
|
||||
syncViewport()
|
||||
}
|
||||
const mutationObserver = new MutationObserver(updateObservation)
|
||||
if (panel) {
|
||||
mutationObserver.observe(panel, {
|
||||
attributes: true,
|
||||
attributeFilter: ['hidden']
|
||||
})
|
||||
}
|
||||
window.addEventListener('resize', syncViewport)
|
||||
window.addEventListener('scroll', syncViewport, true)
|
||||
updateObservation()
|
||||
return () => {
|
||||
if (animationFrame !== undefined) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
mutationObserver.disconnect()
|
||||
window.removeEventListener('resize', syncViewport)
|
||||
window.removeEventListener('scroll', syncViewport, true)
|
||||
void browserApi.setViewport().catch(() => undefined)
|
||||
}
|
||||
}, [conversationId, visible])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={t('sidebar.browser.viewport')}
|
||||
aria-busy={
|
||||
browserState?.status === 'creating' ||
|
||||
browserState?.status === 'loading'
|
||||
}
|
||||
className="assistant-sidebar__browser-viewport"
|
||||
ref={hostRef}
|
||||
>
|
||||
{browserState?.sessionActive !== true ? (
|
||||
<div className="assistant-sidebar__browser-placeholder">
|
||||
{browserState?.status === 'creating'
|
||||
? t('sidebar.browser.statuses.creating')
|
||||
: browserState?.status === 'failed'
|
||||
? browserState.error ?? t('sidebar.browser.statuses.failed')
|
||||
: t('sidebar.browser.empty')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RightAssistantSidebar({
|
||||
open,
|
||||
tab,
|
||||
@@ -491,11 +594,9 @@ export function RightAssistantSidebar({
|
||||
currentProject,
|
||||
restoreFocusRef,
|
||||
onBackBrowser = async () => {},
|
||||
onInteractBrowser,
|
||||
onNavigateBrowser = async () => {},
|
||||
onReloadBrowser = async () => {},
|
||||
onStopLoadingBrowser = async () => {},
|
||||
onStopBrowser,
|
||||
onCreateCustomTask,
|
||||
onImportArtifacts,
|
||||
onLoadArtifact,
|
||||
@@ -586,6 +687,9 @@ export function RightAssistantSidebar({
|
||||
defaultInstances[0]?.id ??
|
||||
null
|
||||
)
|
||||
const activeWorkbarApp = workbarInstances.find(
|
||||
(instance) => instance.id === activeWorkbarInstanceId
|
||||
)?.appId
|
||||
const [splitLayoutWidth, setSplitLayoutWidth] = useState(
|
||||
window.innerWidth
|
||||
)
|
||||
@@ -593,6 +697,8 @@ export function RightAssistantSidebar({
|
||||
initialLayout?.widthRatio ?? defaultSidebarRatio
|
||||
)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [browserFullscreen, setBrowserFullscreen] = useState(false)
|
||||
const browserRestoreRatio = useRef(sidebarRatio)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const wasOpen = useRef(false)
|
||||
const sidebarWidth = clampSidebarWidth(
|
||||
@@ -626,6 +732,12 @@ export function RightAssistantSidebar({
|
||||
}
|
||||
>()
|
||||
const workspacePreviewRequest = useRef(0)
|
||||
const workspaceListRef = useRef<HTMLElement>(null)
|
||||
const workspaceBackRef = useRef<HTMLButtonElement>(null)
|
||||
const workspaceReturnPosition = useRef<{ scrollTop: number; trigger?: HTMLElement }>({ scrollTop: 0 })
|
||||
const workspaceTrigger = useRef<HTMLElement | undefined>(undefined)
|
||||
const [workspacePreviewRefreshing, setWorkspacePreviewRefreshing] = useState(false)
|
||||
const [workspaceSource, setWorkspaceSource] = useState(false)
|
||||
const [workspacePreviewLoadingMore, setWorkspacePreviewLoadingMore] =
|
||||
useState(false)
|
||||
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
|
||||
@@ -655,6 +767,29 @@ export function RightAssistantSidebar({
|
||||
workspacePreview?.projectId === workspaceProjectId
|
||||
? workspacePreview
|
||||
: undefined
|
||||
if (workspacePreview && workspacePreview.projectId !== workspaceProjectId) {
|
||||
setWorkspacePreview(undefined)
|
||||
setWorkspacePreviewRefreshing(false)
|
||||
setWorkspacePreviewLoadingMore(false)
|
||||
setWorkspacePreviewLoadMoreError('')
|
||||
}
|
||||
const workspacePreviewVisible = Boolean(currentWorkspacePreview)
|
||||
useLayoutEffect(() => {
|
||||
const body = workspaceListRef.current?.closest('.assistant-sidebar__body')
|
||||
if (!body) return
|
||||
if (workspacePreviewVisible) {
|
||||
body.scrollTop = 0
|
||||
workspaceBackRef.current?.focus({ preventScroll: true })
|
||||
} else {
|
||||
body.scrollTop = workspaceReturnPosition.current.scrollTop
|
||||
workspaceReturnPosition.current.trigger?.focus({ preventScroll: true })
|
||||
}
|
||||
}, [workspacePreviewVisible])
|
||||
useEffect(() => () => {
|
||||
workspacePreviewRequest.current += 1
|
||||
workspaceReturnPosition.current = { scrollTop: 0 }
|
||||
workspaceTrigger.current = undefined
|
||||
}, [workspaceProjectId])
|
||||
const terminalAdapter = useMemo<TerminalAdapter>(
|
||||
() => ({
|
||||
create: (request) => window.goodbuddy.terminal.create(request),
|
||||
@@ -718,6 +853,40 @@ export function RightAssistantSidebar({
|
||||
}
|
||||
}, [tab, workbarInstances])
|
||||
|
||||
const browserFullscreenActive =
|
||||
open && activeWorkbarApp === 'browser' && browserFullscreen
|
||||
const toggleBrowserFullscreen = useCallback((): void => {
|
||||
if (browserFullscreen) {
|
||||
setSidebarRatio(browserRestoreRatio.current)
|
||||
setBrowserFullscreen(false)
|
||||
return
|
||||
}
|
||||
const layoutWidth = measureSplitLayoutWidth(
|
||||
sidebarRef.current,
|
||||
splitLayoutWidth
|
||||
)
|
||||
browserRestoreRatio.current = sidebarRatio
|
||||
setSidebarRatio(
|
||||
layoutWidth > 0
|
||||
? getSidebarWidthLimits(layoutWidth).maximum / layoutWidth
|
||||
: sidebarRatio
|
||||
)
|
||||
setBrowserFullscreen(true)
|
||||
}, [browserFullscreen, sidebarRatio, splitLayoutWidth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserFullscreenActive) {
|
||||
return
|
||||
}
|
||||
const exitFullscreen = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
toggleBrowserFullscreen()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', exitFullscreen)
|
||||
return () => window.removeEventListener('keydown', exitFullscreen)
|
||||
}, [browserFullscreenActive, toggleBrowserFullscreen])
|
||||
|
||||
useEffect(() => {
|
||||
persistWorkbarLayout(
|
||||
workbarInstances,
|
||||
@@ -1033,17 +1202,26 @@ export function RightAssistantSidebar({
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setBrowserFullscreen(false)
|
||||
const width = clampSidebarWidth(nextWidth, layoutWidth)
|
||||
setSidebarRatio(
|
||||
layoutWidth > 0 ? width / layoutWidth : defaultSidebarRatio
|
||||
)
|
||||
}
|
||||
|
||||
const openWorkspaceFile = (path: string): void => {
|
||||
const openWorkspaceFile = (path: string, refresh = false): void => {
|
||||
if (!currentWorkspacePreview) {
|
||||
workspaceReturnPosition.current = {
|
||||
scrollTop: workspaceListRef.current?.closest('.assistant-sidebar__body')?.scrollTop ?? 0,
|
||||
trigger: workspaceTrigger.current
|
||||
}
|
||||
setWorkspaceSource(false)
|
||||
}
|
||||
const requestId = workspacePreviewRequest.current + 1
|
||||
workspacePreviewRequest.current = requestId
|
||||
const projectId = workspaceProjectId
|
||||
setWorkspacePreview({ projectId, path, state: 'loading' })
|
||||
if (!refresh) setWorkspacePreview({ projectId, path, state: 'loading' })
|
||||
setWorkspacePreviewRefreshing(refresh)
|
||||
setWorkspacePreviewLoadingMore(false)
|
||||
setWorkspacePreviewLoadMoreError('')
|
||||
setActionError('')
|
||||
@@ -1059,8 +1237,15 @@ export function RightAssistantSidebar({
|
||||
setSelectedArtifactId(undefined)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (workspacePreviewRequest.current === requestId) setWorkspacePreviewRefreshing(false)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (workspacePreviewRequest.current === requestId) {
|
||||
if (refresh) {
|
||||
setWorkspacePreviewLoadMoreError(reason instanceof Error ? reason.message : t('sidebar.errors.workspacePreview'))
|
||||
return
|
||||
}
|
||||
setWorkspacePreview({
|
||||
path,
|
||||
projectId,
|
||||
@@ -1079,7 +1264,7 @@ export function RightAssistantSidebar({
|
||||
if (
|
||||
preview?.state !== 'ready' ||
|
||||
!preview.file.truncated ||
|
||||
workspacePreviewLoadingMore
|
||||
workspacePreviewLoadingMore || workspacePreviewRefreshing
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -1152,7 +1337,7 @@ export function RightAssistantSidebar({
|
||||
aria-hidden={!open}
|
||||
className={
|
||||
open
|
||||
? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}`
|
||||
? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}${browserFullscreenActive ? ' assistant-sidebar--browser-fullscreen' : ''}`
|
||||
: 'assistant-sidebar'
|
||||
}
|
||||
id="assistant-sidebar"
|
||||
@@ -1198,6 +1383,7 @@ export function RightAssistantSidebar({
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setBrowserFullscreen(false)
|
||||
resizePointerId.current = event.pointerId
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
resizeFromClientX(event.clientX, true)
|
||||
@@ -1237,7 +1423,9 @@ export function RightAssistantSidebar({
|
||||
) : null
|
||||
}
|
||||
renderPanel={(instance) => (
|
||||
<div className="assistant-sidebar__body">
|
||||
<div
|
||||
className={`assistant-sidebar__body${instance.appId === 'browser' ? ' assistant-sidebar__body--browser' : ''}`}
|
||||
>
|
||||
{actionError ? (
|
||||
<p className="settings-error" role="alert">
|
||||
{actionError}
|
||||
@@ -1476,19 +1664,22 @@ export function RightAssistantSidebar({
|
||||
)}
|
||||
|
||||
{instance.appId === 'workspace' && (
|
||||
currentWorkspacePreview ? (
|
||||
<>
|
||||
{currentWorkspacePreview && (
|
||||
<section
|
||||
aria-busy={currentWorkspacePreview.state === 'loading'}
|
||||
aria-busy={currentWorkspacePreview.state === 'loading' || workspacePreviewRefreshing}
|
||||
className="assistant-sidebar__preview"
|
||||
>
|
||||
<header>
|
||||
<button
|
||||
ref={workspaceBackRef}
|
||||
aria-label={t('sidebar.workspace.back')}
|
||||
className="assistant-sidebar__back"
|
||||
onClick={() => {
|
||||
workspacePreviewRequest.current += 1
|
||||
setWorkspacePreview(undefined)
|
||||
setWorkspacePreviewLoadingMore(false)
|
||||
setWorkspacePreviewRefreshing(false)
|
||||
setWorkspacePreviewLoadMoreError('')
|
||||
setActionError('')
|
||||
}}
|
||||
@@ -1498,7 +1689,7 @@ export function RightAssistantSidebar({
|
||||
{t('sidebar.workspace.title')}
|
||||
</button>
|
||||
<span>
|
||||
<strong>{currentWorkspacePreview.path}</strong>
|
||||
<strong title={currentWorkspacePreview.path}>{currentWorkspacePreview.path}</strong>
|
||||
<small>
|
||||
{currentWorkspacePreview.state === 'ready'
|
||||
? t('sidebar.workspace.fileSize', {
|
||||
@@ -1511,6 +1702,27 @@ export function RightAssistantSidebar({
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<div className="workspace-files__toolbar workspace-files__preview-actions">
|
||||
{currentProject?.executionSpace?.kind !== 'ssh' && (
|
||||
<button className="secondary-button" type="button" onClick={() => {
|
||||
void runAction(() => onOpenWorkspaceEntry(currentWorkspacePreview.path, 'file'), t('files.errors.openFile'))
|
||||
}}><ExternalLink size={14} />{t('sidebar.workspace.openFile')}</button>
|
||||
)}
|
||||
<button className="secondary-button" type="button" onClick={() => {
|
||||
void runAction(() => window.goodbuddy.clipboard.writeText(currentWorkspacePreview.path), t('sidebar.workspace.copyError'))
|
||||
}}><Copy size={14} />{t('sidebar.workspace.copyPath')}</button>
|
||||
{currentWorkspacePreview.state === 'ready' && (
|
||||
<button className="secondary-button" disabled={workspacePreviewRefreshing || workspacePreviewLoadingMore} type="button" onClick={() => openWorkspaceFile(currentWorkspacePreview.path, true)}>
|
||||
<RefreshCw size={14} />{t(workspacePreviewRefreshing ? 'sidebar.workspace.refreshing' : 'sidebar.workspace.refresh')}
|
||||
</button>
|
||||
)}
|
||||
{currentWorkspacePreview.file?.mimeType === 'text/markdown' && (
|
||||
<SegmentedControl ariaLabel={t('sidebar.workspace.title')} value={workspaceSource ? 'source' : 'preview'} onChange={(value) => setWorkspaceSource(value === 'source')} options={[
|
||||
{ value: 'preview', label: t('sidebar.workspace.preview') },
|
||||
{ value: 'source', label: t('sidebar.workspace.source') }
|
||||
]} />
|
||||
)}
|
||||
</div>
|
||||
{currentWorkspacePreview.state === 'loading' ? (
|
||||
<p
|
||||
aria-label={t('sidebar.workspace.reading')}
|
||||
@@ -1538,7 +1750,7 @@ export function RightAssistantSidebar({
|
||||
<>
|
||||
<div className="markdown-body markdown-content">
|
||||
{currentWorkspacePreview.file.mimeType ===
|
||||
'text/markdown' ? (
|
||||
'text/markdown' && !workspaceSource ? (
|
||||
<MarkdownRenderer>
|
||||
{currentWorkspacePreview.file.content}
|
||||
</MarkdownRenderer>
|
||||
@@ -1560,7 +1772,7 @@ export function RightAssistantSidebar({
|
||||
</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={workspacePreviewLoadingMore}
|
||||
disabled={workspacePreviewLoadingMore || workspacePreviewRefreshing}
|
||||
onClick={loadMoreWorkspaceFile}
|
||||
type="button"
|
||||
>
|
||||
@@ -1581,27 +1793,16 @@ export function RightAssistantSidebar({
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="assistant-sidebar__section">
|
||||
<div className="workspace-files__toolbar">
|
||||
<button
|
||||
aria-label={t('sidebar.workspace.refreshAriaLabel')}
|
||||
className="icon-button"
|
||||
disabled={!workspaceProjectId}
|
||||
onClick={() => {
|
||||
void runAction(
|
||||
onRefreshChanges,
|
||||
t('sidebar.errors.refreshWorkspace')
|
||||
)
|
||||
}}
|
||||
title={t('sidebar.workspace.refresh')}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<section ref={workspaceListRef} hidden={workspacePreviewVisible} className="assistant-sidebar__section workspace-files__navigation" onClickCapture={(event) => {
|
||||
if (event.target instanceof Element) workspaceTrigger.current = event.target.closest('button') ?? undefined
|
||||
}}>
|
||||
<WorkspaceFilesPanel
|
||||
rootPath={currentProject?.rootPath}
|
||||
onRefresh={onRefreshChanges}
|
||||
changedFiles={workspaceChanges?.files ?? emptyChangedFiles}
|
||||
gitError={workspaceChanges?.error}
|
||||
isRepository={workspaceChanges?.available}
|
||||
key={workspaceProjectId ?? 'none'}
|
||||
refreshToken={workspaceChanges}
|
||||
onLoadDiff={onLoadWorkspaceDiff}
|
||||
@@ -1610,15 +1811,8 @@ export function RightAssistantSidebar({
|
||||
onOpenFile={openWorkspaceFile}
|
||||
projectId={workspaceProjectId}
|
||||
/>
|
||||
{workspaceChanges?.error && (
|
||||
<p className="workspace-files__status">
|
||||
{t('sidebar.workspace.gitUnavailable', {
|
||||
error: workspaceChanges.error
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
|
||||
{instance.appId === 'results' && (
|
||||
@@ -1738,18 +1932,13 @@ export function RightAssistantSidebar({
|
||||
<BrowserToolbar
|
||||
activeConversationId={activeConversationId}
|
||||
browserState={browserState}
|
||||
fullscreen={browserFullscreenActive}
|
||||
onBack={() =>
|
||||
runAction(
|
||||
onBackBrowser,
|
||||
t('sidebar.errors.backBrowser')
|
||||
)
|
||||
}
|
||||
onInteract={() =>
|
||||
runAction(
|
||||
onInteractBrowser,
|
||||
t('sidebar.errors.interactBrowser')
|
||||
)
|
||||
}
|
||||
onNavigate={(url) =>
|
||||
runAction(
|
||||
() => onNavigateBrowser(url),
|
||||
@@ -1762,77 +1951,21 @@ export function RightAssistantSidebar({
|
||||
t('sidebar.errors.reloadBrowser')
|
||||
)
|
||||
}
|
||||
onStop={() =>
|
||||
runAction(
|
||||
onStopBrowser,
|
||||
t('sidebar.errors.stopBrowser')
|
||||
)
|
||||
}
|
||||
onStopLoading={() =>
|
||||
runAction(
|
||||
onStopLoadingBrowser,
|
||||
t('sidebar.errors.stopLoadingBrowser')
|
||||
)
|
||||
}
|
||||
onToggleFullscreen={() =>
|
||||
toggleBrowserFullscreen()
|
||||
}
|
||||
/>
|
||||
<BrowserViewport
|
||||
browserState={browserState}
|
||||
conversationId={activeConversationId}
|
||||
visible={open}
|
||||
/>
|
||||
<header>
|
||||
<span>
|
||||
<Monitor size={15} />
|
||||
<strong>{t('sidebar.browser.title')}</strong>
|
||||
</span>
|
||||
</header>
|
||||
{!browserState ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
{t('sidebar.browser.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
aria-live={
|
||||
browserState.status === 'failed'
|
||||
? 'assertive'
|
||||
: 'polite'
|
||||
}
|
||||
className={`assistant-sidebar__browser-status assistant-sidebar__browser-status--${browserState.status}`}
|
||||
role={
|
||||
browserState.status === 'failed' ? 'alert' : 'status'
|
||||
}
|
||||
>
|
||||
{browserState.status === 'creating'
|
||||
? t('sidebar.browser.statuses.creating')
|
||||
: browserState.status === 'loading'
|
||||
? t('sidebar.browser.statuses.loading')
|
||||
: browserState.status === 'acting'
|
||||
? t('sidebar.browser.statuses.acting')
|
||||
: browserState.status === 'interactive'
|
||||
? t(
|
||||
'sidebar.browser.statuses.interactive'
|
||||
)
|
||||
: browserState.status === 'ready'
|
||||
? t('sidebar.browser.statuses.ready')
|
||||
: browserState.status === 'failed'
|
||||
? browserState.error ??
|
||||
t('sidebar.browser.statuses.failed')
|
||||
: t('sidebar.browser.statuses.stopped')}
|
||||
</div>
|
||||
{browserState.frameDataUrl ? (
|
||||
<img
|
||||
alt={t('sidebar.browser.frameAlt')}
|
||||
className="assistant-sidebar__browser-frame"
|
||||
src={browserState.frameDataUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="assistant-sidebar__browser-placeholder">
|
||||
<Monitor size={28} />
|
||||
<span>
|
||||
{browserState.status === 'failed'
|
||||
? t('sidebar.browser.noFrame')
|
||||
: t('sidebar.browser.waitingFrame')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowUp, Folder, FolderOpen, RefreshCw } from 'lucide-react'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import type { WorkspaceManagementAction, WorkspaceManagementResult } from '../../shared/workspace-management-contracts'
|
||||
import type { SshDirectoryBrowseResult } from '../../shared/ssh-host-contracts'
|
||||
|
||||
export function WorkspaceActionDialog({ projectId, rootPath, kind, path, onClose, onComplete }: {
|
||||
projectId: string
|
||||
rootPath?: string
|
||||
kind: 'rename' | 'move' | 'delete' | 'properties' | 'createFile' | 'createDirectory'
|
||||
path: string
|
||||
onClose: () => void
|
||||
onComplete: () => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const [value, setValue] = useState(kind === 'rename' ? path.split('/').at(-1)! : '')
|
||||
const [destinationDirectory, setDestinationDirectory] = useState('')
|
||||
const [remotePicker, setRemotePicker] = useState<{ hostId: string; root: string; listing?: SshDirectoryBrowseResult }>()
|
||||
const browseRequest = useRef(0)
|
||||
const remoteBrowsing = useRef(false)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [properties, setProperties] = useState<Extract<WorkspaceManagementResult, { kind: 'properties' }>>()
|
||||
const cancel = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => activateModalFocus(() => cancel.current), [])
|
||||
useEffect(() => () => {
|
||||
browseRequest.current++
|
||||
if (remoteBrowsing.current) void window.goodbuddy.sshHosts?.cancelDirectoryBrowse().catch(() => undefined)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (kind !== 'properties') return
|
||||
let active = true
|
||||
void window.goodbuddy.workspace.manage(projectId, { kind, path }).then((result) => {
|
||||
if (active && result.kind === 'properties') setProperties(result)
|
||||
}).catch((reason: unknown) => { if (active) setError(String(reason)) })
|
||||
return () => { active = false }
|
||||
}, [kind, path, projectId])
|
||||
const selectDestination = (directory: string, root: string, remote: boolean): void => {
|
||||
const windows = !remote && /^(?:[a-z]:[\\/]|\\\\)/i.test(root)
|
||||
const normalize = (input: string): string => {
|
||||
const parts: string[] = []
|
||||
for (const part of (windows ? input.replaceAll('\\', '/') : input).split('/')) {
|
||||
if (part === '..') parts.pop()
|
||||
else if (part && part !== '.') parts.push(part)
|
||||
}
|
||||
return `/${parts.join('/')}`
|
||||
}
|
||||
const base = normalize(root)
|
||||
const selected = normalize(directory)
|
||||
const compare = (input: string): string => windows ? input.toLowerCase() : input
|
||||
const prefix = base === '/' ? '/' : `${base}/`
|
||||
if (compare(selected) !== compare(base) && !compare(selected).startsWith(compare(prefix))) {
|
||||
setValue('')
|
||||
setDestinationDirectory(directory)
|
||||
setError(t('management.destinationOutside', { root }))
|
||||
return
|
||||
}
|
||||
const relative = compare(selected) === compare(base) ? '' : selected.slice(prefix.length)
|
||||
setValue([relative, path.split('/').at(-1)!].filter(Boolean).join('/'))
|
||||
setDestinationDirectory(directory)
|
||||
setError('')
|
||||
setRemotePicker(undefined)
|
||||
remoteBrowsing.current = false
|
||||
}
|
||||
const browseRemote = async (hostId: string, root: string, directory: string): Promise<void> => {
|
||||
const generation = ++browseRequest.current
|
||||
setBusy(true); setError(''); remoteBrowsing.current = true
|
||||
try {
|
||||
const api = window.goodbuddy.sshHosts
|
||||
if (!api) throw new Error(t('management.destinationUnavailable'))
|
||||
const listing = await api.browseDirectories(hostId, directory)
|
||||
if (generation === browseRequest.current) setRemotePicker({ hostId, root, listing })
|
||||
} catch (reason) { if (generation === browseRequest.current) setError(String(reason)) }
|
||||
finally { if (generation === browseRequest.current) setBusy(false) }
|
||||
}
|
||||
const chooseDirectory = async (): Promise<void> => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const project = (await window.goodbuddy.projects.list()).find((item) => item.id === projectId)
|
||||
if (!project) throw new Error(t('management.destinationUnavailable'))
|
||||
const space = project.executionSpace
|
||||
if (space?.kind === 'ssh') {
|
||||
const root = rootPath || space.remoteRootPath
|
||||
setRemotePicker({ hostId: space.hostId, root })
|
||||
await browseRemote(space.hostId, root, root)
|
||||
} else {
|
||||
const root = rootPath || space?.rootPath || project.rootPath
|
||||
if (!root) throw new Error(t('management.destinationUnavailable'))
|
||||
const directory = await window.goodbuddy.settings.selectWorkspace()
|
||||
if (directory) selectDestination(directory, root, false)
|
||||
}
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const submit = async (): Promise<void> => {
|
||||
setError('')
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const parent = path.split('/').slice(0, -1).join('/')
|
||||
const destination = kind === 'rename' ? [parent, value].filter(Boolean).join('/') : value
|
||||
if (kind === 'rename' && /[\\/]/.test(value)) throw new Error(t('management.nameOnly'))
|
||||
const action: WorkspaceManagementAction = kind === 'rename' || kind === 'move' ? { kind: 'move', path, destination }
|
||||
: kind === 'createFile' || kind === 'createDirectory' ? { kind, path: [path, value].filter(Boolean).join('/') }
|
||||
: { kind, path }
|
||||
await window.goodbuddy.workspace.manage(projectId, action)
|
||||
onComplete()
|
||||
onClose()
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface" role="dialog" aria-modal="true" aria-labelledby="workspace-action-title" onKeyDown={(event) => {
|
||||
trapTabFocus(event, event.currentTarget)
|
||||
if (event.key === 'Escape' && (!busy || remotePicker)) onClose()
|
||||
}}>
|
||||
<div className="custom-task-dialog__content">
|
||||
<h3 id="workspace-action-title">{t(`management.${kind}`)}</h3>
|
||||
<p className="workspace-files__path">{path || '/'}</p>
|
||||
{kind === 'delete' ? <p>{t('management.deleteWarning')}</p> : kind === 'properties' ? properties && <dl>
|
||||
<dt>{t('management.path')}</dt><dd>{properties.path}</dd>
|
||||
<dt>{t('management.type')}</dt><dd>{properties.type}</dd>
|
||||
<dt>{t('management.size')}</dt><dd>{properties.size.toLocaleString()} B</dd>
|
||||
<dt>{t('management.modified')}</dt><dd>{new Date(properties.modifiedAt).toLocaleString()}</dd>
|
||||
</dl> : kind === 'move' ? <div className="custom-task-dialog__field">
|
||||
<span id="workspace-destination-label">{t('management.destination')}</span>
|
||||
<button className="secondary-button" type="button" disabled={busy} aria-labelledby="workspace-destination-label workspace-destination-value" aria-describedby={error ? 'workspace-action-error' : 'workspace-destination-help'} onClick={() => void chooseDirectory()}>
|
||||
<FolderOpen size={14} aria-hidden="true" /><span id="workspace-destination-value" className="workspace-files__path">{destinationDirectory || t('management.chooseDirectory')}</span>
|
||||
</button>
|
||||
<small id="workspace-destination-help">{t('management.moveHelp')}</small>
|
||||
{remotePicker && <div className="workspace-files__directory-picker">
|
||||
<div className="remote-directory-picker__location"><code>{remotePicker.listing?.path || remotePicker.root}</code></div>
|
||||
<div className="remote-directory-picker__toolbar">
|
||||
<button className="icon-button" type="button" title={t('management.parent')} aria-label={t('management.parent')} disabled={busy || !remotePicker.listing?.parentPath || remotePicker.listing.path === remotePicker.root} onClick={() => void browseRemote(remotePicker.hostId, remotePicker.root, remotePicker.listing!.parentPath!)}><ArrowUp size={14} /></button>
|
||||
<button className="icon-button" type="button" title={t('sidebar.workspace.refresh')} aria-label={t('sidebar.workspace.refresh')} disabled={busy} onClick={() => void browseRemote(remotePicker.hostId, remotePicker.root, remotePicker.listing?.path || remotePicker.root)}><RefreshCw size={14} /></button>
|
||||
</div>
|
||||
<div className="remote-directory-picker__content" aria-busy={busy}>
|
||||
{busy ? <p role="status">{t('files.reading')}</p> : remotePicker.listing?.entries.length === 0 ? <p>{t('projectSwitcher.remote.directoryPicker.empty')}</p> : <ul>{remotePicker.listing?.entries.map((entry) => <li key={entry.path}><button type="button" onClick={() => void browseRemote(remotePicker.hostId, remotePicker.root, entry.path)}><Folder size={16} aria-hidden="true" /><span>{entry.name}</span></button></li>)}</ul>}
|
||||
</div>
|
||||
{remotePicker.listing?.truncated && <small>{t('files.directoryTruncated')}</small>}
|
||||
<button className="secondary-button" type="button" disabled={busy || !remotePicker.listing} onClick={() => selectDestination(remotePicker.listing!.path, remotePicker.root, true)}>{t('projectSwitcher.remote.directoryPicker.select')}</button>
|
||||
</div>}
|
||||
</div> : <label className="custom-task-dialog__field">{t('management.name')}<input value={value} onChange={(event) => setValue(event.target.value)} aria-describedby={error ? 'workspace-action-error' : undefined} onKeyDown={(event) => { if (event.key === 'Enter') void submit() }} /></label>}
|
||||
{error && <p id="workspace-action-error" role="alert">{error}</p>}
|
||||
<div className="custom-task-dialog__actions">
|
||||
<button ref={cancel} className="secondary-button" type="button" disabled={busy && !remotePicker} onClick={onClose}>{t('management.cancel')}</button>
|
||||
{kind !== 'properties' && <button className={kind === 'delete' ? 'danger-solid' : 'primary-button'} type="button" disabled={busy || Boolean(remotePicker) || (kind !== 'delete' && !value.trim())} onClick={() => void submit()}>{t(`management.${kind}`)}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</section></div>, document.body)
|
||||
}
|
||||
@@ -1,14 +1,178 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { gitStatusLetter, WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
|
||||
beforeEach(() => vi.stubGlobal('goodbuddy', { workspace: { manage: vi.fn(async () => ({ kind: 'branches', current: 'main', branches: [] })) } }))
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
await changeUiLocale('zh-CN')
|
||||
})
|
||||
|
||||
describe('WorkspaceFilesPanel', () => {
|
||||
it('keeps directory navigation and compact actions bounded independently', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src/renderer/src/styles.css'), 'utf8')
|
||||
expect(css.match(/\.workspace-files__actions\s*\{([^}]*)\}/)?.[1]).toContain('flex-wrap: nowrap')
|
||||
expect(css.match(/\.workspace-files__breadcrumb-parts\s*\{([^}]*)\}/)?.[1]).toContain('overflow-x: auto')
|
||||
expect(css.match(/\.workspace-files__breadcrumb-parts button\s*\{([^}]*)\}/)?.[1]).toContain('text-overflow: ellipsis')
|
||||
const menu = css.match(/\.workspace-files__menu\s*\{([^}]*)\}/)?.[1]
|
||||
expect(menu).toContain('position: fixed')
|
||||
expect(menu).toContain('max-width: calc(100vw - 16px)')
|
||||
expect(menu).toContain('max-height: calc(100vh - 16px)')
|
||||
const more = css.match(/\.workspace-files__more\s*\{([^}]*)\}/)?.[1]
|
||||
expect(more).toContain('position: absolute')
|
||||
expect(more).toContain('top: 50%')
|
||||
expect(css).toMatch(/\.workspace-files__menu button\s*\{[^}]*justify-content: flex-start/)
|
||||
expect(css.match(/^\.workspace-git__commit\s*\{([^}]*)\}/m)?.[1]).toContain('grid-template-columns: minmax(0, 1fr)')
|
||||
expect(css).toMatch(/\.workspace-git__toolbar\s*\{[^}]*flex-wrap: wrap;[^}]*gap: var\(--space-2\)/)
|
||||
expect(css).toMatch(/\.workspace-files__view-switch\s*\{[^}]*align-self: flex-start/)
|
||||
expect(css).toMatch(/\.workspace-git__toolbar > \.segmented-control\s*\{[^}]*width: max-content;[^}]*border: 0/)
|
||||
expect(css).toMatch(/\.workspace-git__branch-trigger\s*\{[^}]*width: auto;[^}]*height: 32px;[^}]*flex: 0 1 auto;[^}]*text-align: left/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['D:\\project', 'd:\\project\\target', 'target/notes.txt'],
|
||||
['/project', '/project', 'notes.txt'],
|
||||
['/', '/target', 'target/notes.txt']
|
||||
])('moves into the native directory selection inside %s', async (rootPath, directory, destination) => {
|
||||
const selectWorkspace = vi.fn(async () => directory)
|
||||
const manage = vi.fn(async () => ({ kind: 'done' }))
|
||||
vi.stubGlobal('goodbuddy', { projects: { list: vi.fn(async () => [{ id: 'project', rootPath }]) }, settings: { selectWorkspace }, workspace: { manage } })
|
||||
render(<WorkspaceFilesPanel projectId="project" rootPath={rootPath} changedFiles={[]}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [{ name: 'notes.txt', path: 'source/notes.txt', type: 'file' as const }], truncated: false }))}
|
||||
onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'notes.txt 的更多操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '移动' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '移动' })
|
||||
expect(within(dialog).queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: '取消' })).toHaveClass('secondary-button')
|
||||
expect(within(dialog).getByRole('button', { name: '移动' })).toBeDisabled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '目标目录 选择目录' }))
|
||||
await within(dialog).findByText(directory)
|
||||
expect(selectWorkspace).toHaveBeenCalledOnce()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '移动' }))
|
||||
await waitFor(() => expect(manage).toHaveBeenCalledWith('project', { kind: 'move', path: 'source/notes.txt', destination }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('keeps native picker cancellation and outside-workspace choices from moving files', async () => {
|
||||
const selectWorkspace = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce('D:\\project-other').mockResolvedValueOnce('D:\\project\\..\\outside')
|
||||
const manage = vi.fn()
|
||||
vi.stubGlobal('goodbuddy', { projects: { list: vi.fn(async () => [{ id: 'project', rootPath: 'D:\\project' }]) }, settings: { selectWorkspace }, workspace: { manage } })
|
||||
render(<WorkspaceFilesPanel projectId="project" changedFiles={[]}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [{ name: 'notes.txt', path: 'notes.txt', type: 'file' as const }], truncated: false }))}
|
||||
onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'notes.txt 的更多操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '移动' }))
|
||||
const choose = screen.getByRole('button', { name: '目标目录 选择目录' })
|
||||
fireEvent.click(choose)
|
||||
await waitFor(() => expect(choose).toBeEnabled())
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
fireEvent.click(choose)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('暂不支持移动到此工作区之外')
|
||||
await waitFor(() => expect(choose).toBeEnabled())
|
||||
expect(screen.getByRole('button', { name: '移动' })).toBeDisabled()
|
||||
}
|
||||
expect(manage).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('browses remote move destinations on the project host and maps the selected directory', async () => {
|
||||
const browseDirectories = vi.fn(async (_host: string, path: string) => ({ path, parentPath: '/remote', entries: path === '/remote/project' ? [{ name: 'target', path: '/remote/project/target' }] : [] }))
|
||||
const cancelDirectoryBrowse = vi.fn(async () => undefined)
|
||||
const selectWorkspace = vi.fn()
|
||||
const manage = vi.fn(async () => ({ kind: 'done' }))
|
||||
vi.stubGlobal('goodbuddy', { projects: { list: vi.fn(async () => [{ id: 'project', executionSpace: { kind: 'ssh', hostId: 'host', remoteRootPath: '/remote/project' } }]) }, settings: { selectWorkspace }, sshHosts: { browseDirectories, cancelDirectoryBrowse }, workspace: { manage } })
|
||||
render(<WorkspaceFilesPanel projectId="project" changedFiles={[]}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [{ name: 'notes.txt', path: 'notes.txt', type: 'file' as const }], truncated: false }))}
|
||||
onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'notes.txt 的更多操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '移动' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '目标目录 选择目录' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'target' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '选择此目录' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择此目录' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '移动' }))
|
||||
await waitFor(() => expect(manage).toHaveBeenCalledWith('project', { kind: 'move', path: 'notes.txt', destination: 'target/notes.txt' }))
|
||||
expect(browseDirectories.mock.calls).toEqual([['host', '/remote/project'], ['host', '/remote/project/target']])
|
||||
expect(selectWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses shared Git controls and opens compact commit rows into file details', async () => {
|
||||
const manage = vi.fn(async (_project: string, action: { kind: string }) => action.kind === 'history'
|
||||
? { kind: 'history', commits: [{ oid: 'a'.repeat(40), subject: 'Initial commit', author: 'Author', time: '2026-09-09T10:00:00Z', refs: 'HEAD -> main' }], hasMore: false }
|
||||
: action.kind === 'commitFiles' ? { kind: 'commitFiles', files: [{ path: 'notes.txt', status: 'A' }] }
|
||||
: { kind: 'branches', current: 'main', branches: [] })
|
||||
vi.stubGlobal('goodbuddy', { workspace: { manage } })
|
||||
render(<WorkspaceFilesPanel projectId="project" isRepository changedFiles={[]}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))} onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
expect(await screen.findByRole('button', { name: 'main' })).toHaveClass('model-button')
|
||||
expect(screen.getByRole('button', { name: 'Fetch' })).toHaveClass('secondary-button')
|
||||
expect(screen.getByRole('button', { name: 'Fetch' })).toHaveAttribute('title', 'Fetch')
|
||||
const toolbar = screen.getByRole('button', { name: 'main' }).closest('.workspace-git__toolbar')!
|
||||
expect(within(toolbar as HTMLElement).getByRole('button', { name: '列表' })).toHaveClass('segmented-control__option')
|
||||
const history = screen.getByRole('button', { name: '提交历史' })
|
||||
expect(history).toHaveClass('workspace-files__changed-row')
|
||||
expect(history).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(history)
|
||||
const commit = (await screen.findByText('Initial commit')).closest('button')!
|
||||
expect(commit).toHaveClass('workspace-files__changed-row', 'workspace-git__commit')
|
||||
fireEvent.click(commit)
|
||||
expect(await screen.findByRole('button', { name: /notes\.txt/ })).toHaveClass('workspace-files__changed-row')
|
||||
})
|
||||
|
||||
it('cancels an in-flight remote directory browse without moving or reopening the dialog', async () => {
|
||||
let resolveBrowse!: (value: unknown) => void
|
||||
const browseDirectories = vi.fn(() => new Promise(resolve => { resolveBrowse = resolve }))
|
||||
const cancelDirectoryBrowse = vi.fn(async () => undefined)
|
||||
const manage = vi.fn()
|
||||
vi.stubGlobal('goodbuddy', { projects: { list: vi.fn(async () => [{ id: 'project', executionSpace: { kind: 'ssh', hostId: 'host', remoteRootPath: '/project' } }]) }, sshHosts: { browseDirectories, cancelDirectoryBrowse }, workspace: { manage } })
|
||||
render(<WorkspaceFilesPanel projectId="project" changedFiles={[]}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [{ name: 'notes.txt', path: 'notes.txt', type: 'file' as const }], truncated: false }))}
|
||||
onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'notes.txt 的更多操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '移动' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '目标目录 选择目录' }))
|
||||
await waitFor(() => expect(browseDirectories).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(cancelDirectoryBrowse).toHaveBeenCalledOnce()
|
||||
await act(async () => resolveBrowse({ path: '/project', entries: [] }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(manage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('portals and clamps the row menu, supports keyboard navigation and restores focus', async () => {
|
||||
const { container } = render(<WorkspaceFilesPanel projectId="project" changedFiles={[]}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [{ name: 'notes.txt', path: 'notes.txt', type: 'file' as const }], truncated: false }))}
|
||||
onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
const trigger = await screen.findByRole('button', { name: 'notes.txt 的更多操作' })
|
||||
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ right: window.innerWidth + 50, bottom: window.innerHeight + 50 } as DOMRect)
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
const menu = screen.getByRole('menu', { name: 'notes.txt' })
|
||||
expect(container).not.toContainElement(menu)
|
||||
expect(menu.parentElement).toBe(document.body)
|
||||
expect(parseFloat(menu.style.left)).toBeLessThanOrEqual(window.innerWidth - 8)
|
||||
expect(parseFloat(menu.style.top)).toBeLessThanOrEqual(window.innerHeight - 8)
|
||||
expect(within(menu).getByRole('menuitem', { name: '重命名' })).toHaveFocus()
|
||||
fireEvent.keyDown(menu, { key: 'End' })
|
||||
expect(within(menu).getByRole('menuitem', { name: '删除' })).toHaveFocus()
|
||||
fireEvent.keyDown(menu, { key: 'Home' })
|
||||
fireEvent.keyDown(menu, { key: 'ArrowDown' })
|
||||
expect(within(menu).getByRole('menuitem', { name: '移动' })).toHaveFocus()
|
||||
fireEvent.keyDown(menu, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
it.each([['??', 'U'], [' M', 'M'], ['A ', 'A'], [' D', 'D'], ['R ', 'R'], ['C ', 'C'], ['UU', 'U'], ['AM', 'AM'], [' T', 'T']])('maps %s to %s without calling every new file added', (status, letter) => {
|
||||
expect(gitStatusLetter(status)).toBe(letter)
|
||||
})
|
||||
@@ -57,7 +221,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('当前工作区')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '当前工作区' })).toHaveAttribute('aria-current', 'location')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: 'guide.md' })
|
||||
@@ -66,17 +230,11 @@ describe('WorkspaceFilesPanel', () => {
|
||||
expect(onListDirectory).toHaveBeenCalledWith('')
|
||||
expect(onListDirectory).toHaveBeenCalledWith('docs')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('docs/guide.md')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '使用默认应用打开文件 guide.md'
|
||||
})
|
||||
)
|
||||
fireEvent.click(screen.getByLabelText('guide.md 的更多操作'))
|
||||
fireEvent.click(within(screen.getByRole('menu', { name: 'guide.md' })).getByRole('menuitem', { name: '使用默认应用打开' }))
|
||||
expect(onOpenEntry).toHaveBeenCalledWith('docs/guide.md', 'file')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '在系统资源管理器中打开文件夹 docs'
|
||||
})
|
||||
)
|
||||
fireEvent.click(screen.getByLabelText('docs 的更多操作'))
|
||||
fireEvent.click(within(screen.getByRole('menu', { name: 'docs' })).getByRole('menuitem', { name: '使用默认应用打开' }))
|
||||
expect(onOpenEntry).toHaveBeenCalledWith('docs', 'directory')
|
||||
expect(screen.getAllByText('M')).not.toHaveLength(0)
|
||||
})
|
||||
@@ -138,7 +296,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
}))
|
||||
|
||||
render(
|
||||
<WorkspaceFilesPanel
|
||||
<WorkspaceFilesPanel isRepository
|
||||
onLoadDiff={vi.fn()}
|
||||
changedFiles={[{ path: 'notes.txt', status: ' M' }]}
|
||||
onListDirectory={onListDirectory}
|
||||
@@ -148,18 +306,18 @@ describe('WorkspaceFilesPanel', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText('当前工作区')
|
||||
await screen.findByText('工作区为空。')
|
||||
expect(onListDirectory).toHaveBeenCalledOnce()
|
||||
await changeUiLocale('en-US')
|
||||
|
||||
expect(
|
||||
await screen.findByText('Current workspace')
|
||||
await screen.findByRole('button', { name: 'Current workspace' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Uncommitted changes')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git Workspace' }))
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
expect(onListDirectory).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('refreshes expanded directories and drops collapsed caches without remounting', async () => {
|
||||
it('refreshes expanded directories and reloads collapsed directories when reopened', async () => {
|
||||
let version = 0
|
||||
const onListDirectory = vi.fn(async (path: string) => ({
|
||||
path,
|
||||
@@ -188,15 +346,17 @@ describe('WorkspaceFilesPanel', () => {
|
||||
it('opens a deleted file diff rather than reading the missing file', async () => {
|
||||
const onLoadDiff = vi.fn(async () => ({ rootPath: '/project', available: true, files: [], status: '', patch: '-deleted content', stagedPatch: '-staged content', truncated: true }))
|
||||
const onOpenFile = vi.fn()
|
||||
render(<WorkspaceFilesPanel projectId="project" changedFiles={[{ path: 'deleted.txt', status: ' D' }]}
|
||||
render(<WorkspaceFilesPanel isRepository projectId="project" changedFiles={[{ path: 'deleted.txt', status: ' D' }]}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))}
|
||||
onLoadDiff={onLoadDiff} onOpenFile={onOpenFile} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /deleted.txt/ }))
|
||||
await screen.findByText('-deleted content')
|
||||
expect(screen.getByText('-staged content')).toBeInTheDocument()
|
||||
expect(screen.getByText('差异内容已截断。')).toBeInTheDocument()
|
||||
expect(onLoadDiff).toHaveBeenCalledWith('deleted.txt')
|
||||
expect(onOpenFile).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: '查看当前文件' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reloads the selected diff on refresh and ignores an older pending response', async () => {
|
||||
@@ -206,12 +366,13 @@ describe('WorkspaceFilesPanel', () => {
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
.mockResolvedValue(value)
|
||||
const props = {
|
||||
projectId: 'project', changedFiles: [{ path: 'seed.txt', status: ' M' }],
|
||||
isRepository: true, projectId: 'project', changedFiles: [{ path: 'seed.txt', status: ' M' }],
|
||||
onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })),
|
||||
onLoadDiff, onOpenFile: vi.fn()
|
||||
}
|
||||
const view = render(<WorkspaceFilesPanel {...props} refreshToken={0} />)
|
||||
await waitFor(() => expect(props.onListDirectory).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /seed.txt/ }))
|
||||
view.rerender(<WorkspaceFilesPanel {...props} refreshToken={1} />)
|
||||
await screen.findByText('+fresh')
|
||||
@@ -225,16 +386,125 @@ describe('WorkspaceFilesPanel', () => {
|
||||
rootPath: '/project', available: true, files: [], status: '',
|
||||
patch: '-last deleted', stagedPatch: '', truncated: false
|
||||
}))
|
||||
render(<WorkspaceFilesPanel
|
||||
render(<WorkspaceFilesPanel isRepository
|
||||
projectId="project"
|
||||
changedFiles={Array.from({ length: 51 }, (_, index) => ({ path: `deleted-${index}.txt`, status: ' D' }))}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))}
|
||||
onLoadDiff={onLoadDiff} onOpenFile={vi.fn()}
|
||||
/>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
expect(screen.queryByRole('button', { name: /deleted-50.txt/ })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '加载更多变更(剩余 1 个)' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /deleted-50.txt/ }))
|
||||
await screen.findByText('-last deleted')
|
||||
expect(onLoadDiff).toHaveBeenCalledWith('deleted-50.txt')
|
||||
})
|
||||
|
||||
it('preserves mounted rows, expansion, selection, view and scroll when returning from a diff', async () => {
|
||||
const onListDirectory = vi.fn(async (path: string) => ({ path, truncated: false,
|
||||
entries: path ? [{ name: 'guide.md', path: 'docs/guide.md', type: 'file' as const }]
|
||||
: [{ name: 'docs', path: 'docs', type: 'directory' as const }] }))
|
||||
const onOpenFile = vi.fn()
|
||||
const props = { isRepository: true, projectId: 'project', changedFiles: [{ path: 'docs/guide.md', status: ' M' }], onListDirectory,
|
||||
onLoadDiff: vi.fn(async () => ({ rootPath: '/project', available: true, files: [], status: '', patch: '+updated', truncated: false })), onOpenFile }
|
||||
const { container, rerender } = render(<div className="assistant-sidebar__body"><section><WorkspaceFilesPanel {...props} /></section></div>)
|
||||
expect(screen.getByRole('button', { name: '文件' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs' }))
|
||||
const fileRow = (await screen.findByText('guide.md')).closest('button')!
|
||||
fireEvent.click(fileRow)
|
||||
expect(fileRow).toHaveAttribute('aria-current', 'true')
|
||||
rerender(<div className="assistant-sidebar__body"><section hidden><WorkspaceFilesPanel {...props} /></section></div>)
|
||||
// Simulate the parent hiding its existing section without unmounting the panel.
|
||||
rerender(<div className="assistant-sidebar__body"><section><WorkspaceFilesPanel {...props} /></section></div>)
|
||||
expect(screen.getByText('guide.md').closest('button')).toBe(fileRow)
|
||||
expect(fileRow).toHaveAttribute('aria-current', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
const changedRow = screen.getByRole('button', { name: /docs\/guide\.md/ })
|
||||
const treeRow = container.querySelector('.workspace-files__row[title="docs/guide.md"]')
|
||||
const scroller = container.firstElementChild as HTMLElement
|
||||
scroller.scrollTop = 340
|
||||
scroller.scrollLeft = 12
|
||||
fireEvent.click(changedRow)
|
||||
await screen.findByText('+updated')
|
||||
expect(treeRow).toBeInTheDocument()
|
||||
expect(changedRow).not.toBeVisible()
|
||||
expect(scroller.scrollTop).toBe(0)
|
||||
expect(screen.getByRole('button', { name: '返回未提交更改' })).toHaveFocus()
|
||||
scroller.scrollTop = 90
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回未提交更改' }))
|
||||
expect(changedRow).toHaveFocus()
|
||||
expect(changedRow).toHaveAttribute('aria-current', 'true')
|
||||
expect(scroller.scrollTop).toBe(340)
|
||||
expect(scroller.scrollLeft).toBe(12)
|
||||
expect(screen.getByRole('button', { name: 'Git 工作区' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: '文件' }))
|
||||
expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('guide.md').closest('button')).toBe(treeRow)
|
||||
})
|
||||
|
||||
it('retains root and expanded listings on refresh failure and retries each directory locally', async () => {
|
||||
let fail = false
|
||||
const onListDirectory = vi.fn(async (path: string) => {
|
||||
if (fail) throw new Error(`Cannot read ${path || 'root'}`)
|
||||
return { path, truncated: false, entries: path
|
||||
? [{ name: 'guide.md', path: 'docs/guide.md', type: 'file' as const }]
|
||||
: [{ name: 'docs', path: 'docs', type: 'directory' as const }] }
|
||||
})
|
||||
const props = { projectId: 'project', changedFiles: [], onListDirectory, onLoadDiff: vi.fn(), onOpenFile: vi.fn() }
|
||||
const { rerender } = render(<WorkspaceFilesPanel {...props} refreshToken={0} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs' }))
|
||||
const row = await screen.findByRole('button', { name: 'guide.md' })
|
||||
fail = true
|
||||
rerender(<WorkspaceFilesPanel {...props} refreshToken={1} />)
|
||||
await screen.findByText('Cannot read root')
|
||||
expect(screen.getByText('Cannot read docs')).toBeInTheDocument()
|
||||
expect(row).toBeVisible()
|
||||
fail = false
|
||||
const calls = onListDirectory.mock.calls.length
|
||||
fireEvent.click(screen.getByText('Cannot read docs').querySelector('button')!)
|
||||
await waitFor(() => expect(screen.queryByText('Cannot read docs')).not.toBeInTheDocument())
|
||||
expect(onListDirectory).toHaveBeenCalledTimes(calls + 1)
|
||||
expect(onListDirectory).toHaveBeenLastCalledWith('docs')
|
||||
fireEvent.click(screen.getByText('Cannot read root').querySelector('button')!)
|
||||
await waitFor(() => expect(screen.queryByText('Cannot read root')).not.toBeInTheDocument())
|
||||
expect(onListDirectory).toHaveBeenLastCalledWith('')
|
||||
})
|
||||
|
||||
it('shows Git errors and empty states only in changes view', async () => {
|
||||
const props = { projectId: 'project', changedFiles: [], onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })), onLoadDiff: vi.fn(), onOpenFile: vi.fn() }
|
||||
const { rerender } = render(<WorkspaceFilesPanel {...props} gitError="Git offline" />)
|
||||
await screen.findByText('工作区为空。')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Git 工作区' })).not.toBeInTheDocument()
|
||||
rerender(<WorkspaceFilesPanel {...props} isRepository={false} />)
|
||||
expect(screen.queryByRole('button', { name: 'Git 工作区' })).not.toBeInTheDocument()
|
||||
rerender(<WorkspaceFilesPanel {...props} isRepository />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
expect(screen.getByText(/没有未提交的更改/)).toBeVisible()
|
||||
rerender(<WorkspaceFilesPanel {...props} isRepository={false} gitError="Git offline" />)
|
||||
expect(screen.getByRole('button', { name: 'Git 工作区' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Git offline')
|
||||
})
|
||||
|
||||
it('views the current file, refreshes a diff and copies its relative path with recoverable errors', async () => {
|
||||
const writeText = vi.fn().mockRejectedValueOnce(new Error('Clipboard busy')).mockResolvedValue(undefined)
|
||||
vi.stubGlobal('goodbuddy', { ...window.goodbuddy, clipboard: { writeText } })
|
||||
const onOpenFile = vi.fn()
|
||||
const onLoadDiff = vi.fn(async () => ({ rootPath: '/project', available: true, files: [], status: '', patch: '+content', truncated: false }))
|
||||
const onListDirectory = vi.fn(async () => ({ path: '', entries: [], truncated: false }))
|
||||
render(<WorkspaceFilesPanel isRepository projectId="project" changedFiles={[{ path: 'docs/a.md', status: ' M' }]} onOpenFile={onOpenFile} onLoadDiff={onLoadDiff} onListDirectory={onListDirectory} />)
|
||||
await waitFor(() => expect(onListDirectory).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /docs\/a\.md/ }))
|
||||
await screen.findByText('+content')
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看当前文件' }))
|
||||
expect(onOpenFile).toHaveBeenCalledWith('docs/a.md')
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(onLoadDiff).toHaveBeenCalledTimes(2))
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制相对路径' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('复制路径失败。 Clipboard busy')
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制相对路径' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(writeText).toHaveBeenLastCalledWith('docs/a.md')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,20 +2,35 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
FileSearch,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
FilePlus,
|
||||
FolderPlus,
|
||||
Copy,
|
||||
Edit3,
|
||||
ExternalLink,
|
||||
FolderInput,
|
||||
Info,
|
||||
Trash2,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderRoot,
|
||||
FolderOpen
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { WorkspaceActionDialog } from './WorkspaceActionDialog'
|
||||
import { WorkspaceGitTools } from './WorkspaceGitTools'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
@@ -25,7 +40,11 @@ import type {
|
||||
|
||||
type WorkspaceFilesPanelProps = {
|
||||
projectId?: string
|
||||
rootPath?: string
|
||||
onRefresh?: () => Promise<void>
|
||||
changedFiles: WorkspaceChangedFile[]
|
||||
gitError?: string
|
||||
isRepository?: boolean
|
||||
refreshToken?: unknown
|
||||
onLoadDiff: (path: string) => Promise<WorkspaceChanges>
|
||||
onListDirectory: (path: string) => Promise<WorkspaceDirectoryListing>
|
||||
@@ -42,9 +61,25 @@ export function gitStatusLetter(status: string): string {
|
||||
return status.trim().replaceAll('.', '').split('').filter((value, index, values) => values.indexOf(value) === index).join('')
|
||||
}
|
||||
|
||||
function changedTree(entries: { path: string; row: React.JSX.Element }[], prefix = ''): React.JSX.Element {
|
||||
const groups = new Map<string, typeof entries>()
|
||||
const files = entries.filter((entry) => !entry.path.slice(prefix.length).includes('/'))
|
||||
for (const entry of entries) {
|
||||
const rest = entry.path.slice(prefix.length)
|
||||
if (!rest.includes('/')) continue
|
||||
const directory = rest.split('/')[0]!
|
||||
groups.set(directory, [...(groups.get(directory) ?? []), entry])
|
||||
}
|
||||
return <>{[...groups].map(([name, children]) => <details key={name} open><summary>{name}</summary><div className="workspace-files__children">{changedTree(children, `${prefix}${name}/`)}</div></details>)}{files.map((entry) => entry.row)}</>
|
||||
}
|
||||
|
||||
export function WorkspaceFilesPanel({
|
||||
projectId,
|
||||
rootPath,
|
||||
onRefresh,
|
||||
changedFiles,
|
||||
gitError,
|
||||
isRepository,
|
||||
refreshToken,
|
||||
onLoadDiff,
|
||||
onListDirectory,
|
||||
@@ -70,22 +105,99 @@ export function WorkspaceFilesPanel({
|
||||
}>({ value: new Set() })
|
||||
const [errorState, setErrorState] = useState<{
|
||||
projectId?: string
|
||||
value?: string
|
||||
}>({})
|
||||
value: Record<string, string | undefined>
|
||||
}>({ value: {} })
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [menu, setMenu] = useState<{ entry: WorkspaceDirectoryEntry; trigger: HTMLButtonElement }>()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const closeMenu = (restoreFocus = false): void => {
|
||||
if (restoreFocus) menu?.trigger.focus({ preventScroll: true })
|
||||
setMenu(undefined)
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
const surface = menuRef.current
|
||||
if (!menu || !surface) return
|
||||
const position = (): void => {
|
||||
const anchor = menu.trigger.getBoundingClientRect()
|
||||
const bounds = surface.getBoundingClientRect()
|
||||
surface.style.left = `${Math.max(8, Math.min(anchor.right - bounds.width, window.innerWidth - bounds.width - 8))}px`
|
||||
surface.style.top = `${Math.max(8, Math.min(anchor.bottom + 4, window.innerHeight - bounds.height - 8))}px`
|
||||
}
|
||||
position()
|
||||
surface.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true })
|
||||
const dismiss = (event: Event): void => {
|
||||
if (event.target instanceof Node && !surface.contains(event.target) && !menu.trigger.contains(event.target)) setMenu(undefined)
|
||||
}
|
||||
document.addEventListener('pointerdown', dismiss)
|
||||
document.addEventListener('focusin', dismiss)
|
||||
window.addEventListener('resize', position)
|
||||
window.addEventListener('scroll', position, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', dismiss)
|
||||
document.removeEventListener('focusin', dismiss)
|
||||
window.removeEventListener('resize', position)
|
||||
window.removeEventListener('scroll', position, true)
|
||||
}
|
||||
}, [menu])
|
||||
const [view, setView] = useState<'files' | 'changes'>('files')
|
||||
const [browsedPath, setBrowsedPath] = useState('')
|
||||
const [createTarget, setCreateTarget] = useState('')
|
||||
const [dialog, setDialog] = useState<{ kind: 'rename' | 'move' | 'delete' | 'properties' | 'createFile' | 'createDirectory'; path: string }>()
|
||||
const [changeView, setChangeView] = useState<'list' | 'tree'>('list')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const repositoryKnown = useRef<{ projectId?: string; available: boolean }>({ available: false })
|
||||
useEffect(() => { if (!gitError && isRepository !== undefined) repositoryKnown.current = { projectId, available: isRepository } }, [gitError, isRepository, projectId])
|
||||
const showGit = isRepository === true || (Boolean(gitError) && repositoryKnown.current.projectId === projectId && repositoryKnown.current.available)
|
||||
const activeView = showGit ? view : 'files'
|
||||
const [selected, setSelected] = useState<{ projectId?: string; path: string }>()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const backRef = useRef<HTMLButtonElement>(null)
|
||||
const returnContext = useRef<{ scrollTop: number; scrollLeft: number; trigger: HTMLButtonElement } | undefined>(undefined)
|
||||
const requestGeneration = useRef(0)
|
||||
const inFlightPaths = useRef(new Set<string>())
|
||||
const [diff, setDiff] = useState<{ path: string; value?: WorkspaceChanges; error?: string }>()
|
||||
const [diffState, setDiff] = useState<{ projectId?: string; path: string; value?: WorkspaceChanges; error?: string }>()
|
||||
const diff = diffState?.projectId === projectId ? diffState : undefined
|
||||
const diffRequest = useRef(0)
|
||||
const [visibleChangeCount, setVisibleChangeCount] = useState(50)
|
||||
const openDiff = (path: string): void => {
|
||||
const request = ++diffRequest.current
|
||||
setDiff({ path })
|
||||
setDiff({ projectId, path })
|
||||
void onLoadDiff(path).then((value) => {
|
||||
if (request === diffRequest.current) setDiff({ path, value, error: value.error })
|
||||
if (request === diffRequest.current) setDiff({ projectId, path, value, error: value.error })
|
||||
}).catch((reason: unknown) => {
|
||||
if (request === diffRequest.current) setDiff({ path, error: reason instanceof Error ? reason.message : t('files.errors.read') })
|
||||
if (request === diffRequest.current) setDiff({ projectId, path, error: reason instanceof Error ? reason.message : t('files.errors.read') })
|
||||
})
|
||||
}
|
||||
const showingDiff = Boolean(diff)
|
||||
useLayoutEffect(() => {
|
||||
const scroller = panelRef.current?.closest<HTMLElement>('.assistant-sidebar__body')
|
||||
if (showingDiff) {
|
||||
if (scroller) {
|
||||
scroller.scrollTop = 0
|
||||
scroller.scrollLeft = 0
|
||||
}
|
||||
backRef.current?.focus({ preventScroll: true })
|
||||
} else if (returnContext.current) {
|
||||
const context = returnContext.current
|
||||
if (context.trigger.isConnected) {
|
||||
context.trigger.focus({ preventScroll: true })
|
||||
if (scroller) {
|
||||
scroller.scrollTop = context.scrollTop
|
||||
scroller.scrollLeft = context.scrollLeft
|
||||
}
|
||||
}
|
||||
returnContext.current = undefined
|
||||
}
|
||||
}, [showingDiff])
|
||||
|
||||
const copyPath = async (path: string): Promise<void> => {
|
||||
setActionError('')
|
||||
try {
|
||||
await window.goodbuddy.clipboard.writeText(path)
|
||||
} catch (reason) {
|
||||
setActionError(reason instanceof Error ? `${t('sidebar.workspace.copyError')} ${reason.message}` : t('sidebar.workspace.copyError'))
|
||||
}
|
||||
}
|
||||
|
||||
const loadDirectory = useCallback(
|
||||
async (path: string, generation: number): Promise<void> => {
|
||||
@@ -112,16 +224,18 @@ export function WorkspaceFilesPanel({
|
||||
[path]: listing
|
||||
}
|
||||
}))
|
||||
setErrorState({ projectId })
|
||||
setErrorState((current) => ({ projectId, value: {
|
||||
...(current.projectId === projectId ? current.value : {}), [path]: undefined
|
||||
} }))
|
||||
} catch (reason) {
|
||||
if (requestGeneration.current === generation) {
|
||||
setErrorState({
|
||||
setErrorState((current) => ({
|
||||
projectId,
|
||||
value:
|
||||
value: { ...(current.projectId === projectId ? current.value : {}), [path]:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: tRef.current('files.errors.read')
|
||||
})
|
||||
: tRef.current('files.errors.read') }
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
if (requestGeneration.current === generation) {
|
||||
@@ -141,7 +255,6 @@ export function WorkspaceFilesPanel({
|
||||
|
||||
const refreshDirectories = useEffectEvent((generation: number) => {
|
||||
const paths = expandedState.projectId === projectId ? [...expandedState.value] : []
|
||||
setListingState({ projectId, value: {} })
|
||||
setLoadingState({ projectId, value: new Set() })
|
||||
void loadDirectory('', generation)
|
||||
for (const path of paths) void loadDirectory(path, generation)
|
||||
@@ -185,10 +298,18 @@ export function WorkspaceFilesPanel({
|
||||
loadingState.projectId === projectId
|
||||
? loadingState.value
|
||||
: emptyPaths
|
||||
const error =
|
||||
errorState.projectId === projectId ? errorState.value : undefined
|
||||
const errors = errorState.projectId === projectId ? errorState.value : {}
|
||||
const renderDirectoryError = (path: string): React.JSX.Element | null => errors[path] ? (
|
||||
<p className="workspace-files__error" role="alert">
|
||||
{errors[path]}
|
||||
<button type="button" disabled={loadingPaths.has(path)} onClick={() => void loadDirectory(path, requestGeneration.current)}>
|
||||
{t('files.retry')}
|
||||
</button>
|
||||
</p>
|
||||
) : null
|
||||
|
||||
const toggleDirectory = (path: string): void => {
|
||||
setCreateTarget(path)
|
||||
const expanding = !expandedPaths.has(path)
|
||||
setExpandedState((current) => {
|
||||
const next = new Set(
|
||||
@@ -201,26 +322,52 @@ export function WorkspaceFilesPanel({
|
||||
}
|
||||
return { projectId, value: next }
|
||||
})
|
||||
if (expanding && !listings[path]) {
|
||||
if (expanding) {
|
||||
void loadDirectory(path, requestGeneration.current)
|
||||
}
|
||||
}
|
||||
|
||||
const openEntry = (entry: WorkspaceDirectoryEntry): void => {
|
||||
setErrorState({ projectId })
|
||||
setActionError('')
|
||||
void onOpenEntry?.(entry.path, entry.type).catch((reason: unknown) => {
|
||||
setErrorState({
|
||||
projectId,
|
||||
value:
|
||||
setActionError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: entry.type === 'directory'
|
||||
? t('files.errors.openFolder')
|
||||
: t('files.errors.openFile')
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await onRefresh?.()
|
||||
await loadDirectory('', requestGeneration.current)
|
||||
if (browsedPath) await loadDirectory(browsedPath, requestGeneration.current)
|
||||
for (const path of expandedPaths) await loadDirectory(path, requestGeneration.current)
|
||||
} catch (reason) { setActionError(String(reason)) }
|
||||
finally { setRefreshing(false) }
|
||||
}
|
||||
const browse = (path: string): void => {
|
||||
setBrowsedPath(path); setCreateTarget(path)
|
||||
void loadDirectory(path, requestGeneration.current)
|
||||
}
|
||||
const entryMenu = (entry: WorkspaceDirectoryEntry): React.JSX.Element => (
|
||||
<button className="icon-button workspace-files__more" type="button"
|
||||
aria-label={t('management.more', { name: entry.name })}
|
||||
title={t('management.more', { name: entry.name })}
|
||||
aria-haspopup="menu" aria-expanded={menu?.entry.path === entry.path}
|
||||
onClick={(event) => setMenu(menu?.entry.path === entry.path ? undefined : { entry, trigger: event.currentTarget })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setMenu({ entry, trigger: event.currentTarget })
|
||||
}
|
||||
}}><MoreHorizontal size={14} aria-hidden="true" /></button>
|
||||
)
|
||||
|
||||
const renderEntry = (
|
||||
entry: WorkspaceDirectoryEntry
|
||||
): React.JSX.Element => {
|
||||
@@ -245,17 +392,7 @@ export function WorkspaceFilesPanel({
|
||||
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
|
||||
<span title={entry.path}>{entry.name}</span>
|
||||
</button>
|
||||
{onOpenEntry && <button
|
||||
aria-label={t('files.openFolderAriaLabel', {
|
||||
name: entry.name
|
||||
})}
|
||||
className="workspace-files__open-entry"
|
||||
onClick={() => openEntry(entry)}
|
||||
title={t('files.openFolder')}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>}
|
||||
{entryMenu(entry)}
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="workspace-files__children">
|
||||
@@ -267,6 +404,7 @@ export function WorkspaceFilesPanel({
|
||||
{t('files.reading')}
|
||||
</p>
|
||||
)}
|
||||
{renderDirectoryError(entry.path)}
|
||||
{listing?.truncated && (
|
||||
<p className="workspace-files__status">
|
||||
{t('files.directoryTruncated')}
|
||||
@@ -281,7 +419,12 @@ export function WorkspaceFilesPanel({
|
||||
<div className="workspace-files__entry" key={entry.path}>
|
||||
<button
|
||||
className="workspace-files__row"
|
||||
onClick={() => onOpenFile(entry.path)}
|
||||
aria-current={selected?.projectId === projectId && selected?.path === entry.path ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
setSelected({ projectId, path: entry.path })
|
||||
setCreateTarget(entry.path.split('/').slice(0, -1).join('/'))
|
||||
onOpenFile(entry.path)
|
||||
}}
|
||||
title={entry.path}
|
||||
type="button"
|
||||
>
|
||||
@@ -294,17 +437,7 @@ export function WorkspaceFilesPanel({
|
||||
</small>
|
||||
)}
|
||||
</button>
|
||||
{onOpenEntry && <button
|
||||
aria-label={t('files.openFileAriaLabel', {
|
||||
name: entry.name
|
||||
})}
|
||||
className="workspace-files__open-entry"
|
||||
onClick={() => openEntry(entry)}
|
||||
title={t('files.openFile')}
|
||||
type="button"
|
||||
>
|
||||
<FileSearch size={14} />
|
||||
</button>}
|
||||
{entryMenu(entry)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -317,7 +450,7 @@ export function WorkspaceFilesPanel({
|
||||
)
|
||||
}
|
||||
|
||||
const root = listings['']
|
||||
const root = listings[browsedPath]
|
||||
const renderPatch = (patch: string): React.JSX.Element => (
|
||||
<pre className="assistant-sidebar__diff workspace-files__diff">
|
||||
{patch.split('\n').map((line, index) => (
|
||||
@@ -330,15 +463,22 @@ export function WorkspaceFilesPanel({
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
if (diff) {
|
||||
return <section className="assistant-sidebar__preview" aria-busy={!diff.value && !diff.error}>
|
||||
return (
|
||||
<div ref={panelRef} className="workspace-files">
|
||||
{diff && <section className="assistant-sidebar__preview" aria-busy={!diff.value && !diff.error}>
|
||||
<header>
|
||||
<button className="assistant-sidebar__back" type="button" onClick={() => {
|
||||
<button ref={backRef} className="assistant-sidebar__back" type="button" onClick={() => {
|
||||
diffRequest.current += 1
|
||||
setDiff(undefined)
|
||||
}}><ChevronLeft size={14} />{t('files.currentWorkspace')}</button>
|
||||
setActionError('')
|
||||
}}><ChevronLeft size={14} />{t('files.backToChanges')}</button>
|
||||
<strong>{diff.path}</strong>
|
||||
</header>
|
||||
<div className="workspace-files__toolbar">
|
||||
{!changedByPath.get(diff.path)?.status.includes('D') && <button type="button" className="secondary-button" onClick={() => onOpenFile(diff.path)}>{t('files.viewCurrentFile')}</button>}
|
||||
<button type="button" className="secondary-button" disabled={!diff.value && !diff.error} onClick={() => openDiff(diff.path)}>{t(!diff.value && !diff.error ? 'sidebar.workspace.refreshing' : 'sidebar.workspace.refresh')}</button>
|
||||
<button type="button" className="secondary-button" onClick={() => void copyPath(diff.path)}>{t('sidebar.workspace.copyPath')}</button>
|
||||
</div>
|
||||
{diff.error ? <p role="alert">{diff.error}<button type="button" onClick={() => openDiff(diff.path)}>{t('files.retry')}</button></p>
|
||||
: !diff.value ? <p>{t('files.reading')}</p> : <>
|
||||
{diff.value.stagedPatch && <><h4>{t('files.staged')}</h4>{renderPatch(diff.value.stagedPatch)}</>}
|
||||
@@ -346,30 +486,56 @@ export function WorkspaceFilesPanel({
|
||||
{!diff.value.patch && !diff.value.stagedPatch && <p>{t('files.noDiff')}</p>}
|
||||
{diff.value.truncated && <p>{t('files.diffTruncated')}</p>}
|
||||
</>}
|
||||
</section>
|
||||
}
|
||||
return (
|
||||
<div className="workspace-files">
|
||||
{changedFiles.length > 0 && (
|
||||
<div className="workspace-files__changed">
|
||||
<strong>{t('files.changedTitle')}</strong>
|
||||
{changedFiles.slice(0, visibleChangeCount).map((file) => {
|
||||
return (
|
||||
</section>}
|
||||
<div className="workspace-files__list" hidden={showingDiff}>
|
||||
<nav className="workspace-files__breadcrumbs" aria-label={t('management.path')}>
|
||||
{browsedPath && <button className="icon-button" type="button" aria-label={t('management.parent')} title={t('management.parent')} onClick={() => browse(browsedPath.split('/').slice(0, -1).join('/'))}><ChevronLeft size={14} /></button>}
|
||||
<div className="workspace-files__breadcrumb-parts">
|
||||
<button className="icon-button" type="button" onClick={() => browse('')} aria-label={rootPath || t('files.currentWorkspace')} title={rootPath || t('files.currentWorkspace')} aria-current={!browsedPath ? 'location' : undefined}><FolderRoot size={14} aria-hidden="true" /></button>
|
||||
{browsedPath.split('/').filter(Boolean).map((part, index, parts) => <button type="button" key={index} title={parts.slice(0, index + 1).join('/')} aria-current={index === parts.length - 1 ? 'location' : undefined} onClick={() => browse(parts.slice(0, index + 1).join('/'))}>{part}</button>)}
|
||||
</div>
|
||||
</nav>
|
||||
{root && <div className="workspace-files__toolbar workspace-files__actions">
|
||||
<button className="icon-button" type="button" disabled={refreshing} aria-label={t('sidebar.workspace.refreshAriaLabel')} title={t('sidebar.workspace.refresh')} onClick={() => void refresh()}><RefreshCw size={14} /></button>
|
||||
<button className="icon-button" type="button" aria-label={t('management.createFile')} title={`${t('management.createFile')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createFile', path: createTarget })}><FilePlus size={14} /></button>
|
||||
<button className="icon-button" type="button" aria-label={t('management.createDirectory')} title={`${t('management.createDirectory')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createDirectory', path: createTarget })}><FolderPlus size={14} /></button>
|
||||
</div>}
|
||||
{showGit && <div className="workspace-files__view-switch"><SegmentedControl ariaLabel={t('files.view')} value={activeView} onChange={setView} options={[
|
||||
{ value: 'files', label: t('files.filesView') },
|
||||
{ value: 'changes', label: t('management.gitWorkspace') }
|
||||
]} /></div>}
|
||||
<div hidden={activeView !== 'changes'}>
|
||||
{showGit && <WorkspaceGitTools projectId={projectId} refreshToken={refreshToken} onRefresh={refresh} viewControl={
|
||||
<SegmentedControl ariaLabel={t('management.changedView')} value={changeView} onChange={setChangeView} options={[{ value: 'list', label: t('management.list') }, { value: 'tree', label: t('management.tree') }]} />
|
||||
}>
|
||||
{gitError ? <p className="workspace-files__status" role="status">{t('sidebar.workspace.gitUnavailable', { error: gitError })}</p>
|
||||
: isRepository === false ? <p className="assistant-sidebar__empty">{t('files.notRepository')}</p>
|
||||
: changedFiles.length === 0 ? <p className="assistant-sidebar__empty">{t('files.noChanges')}</p> : null}
|
||||
<div className="workspace-files__changed" data-view={changeView}>
|
||||
{((entries: { path: string; row: React.JSX.Element }[]) => changeView === 'tree' ? changedTree(entries) : entries.map((entry) => entry.row))(changedFiles.slice(0, visibleChangeCount).map((file) => {
|
||||
return { path: file.path, row: (
|
||||
<button
|
||||
className="workspace-files__changed-row"
|
||||
key={`${file.status}:${file.path}`}
|
||||
onClick={() => openDiff(file.path)}
|
||||
key={file.path}
|
||||
aria-current={selected?.projectId === projectId && selected?.path === file.path ? 'true' : undefined}
|
||||
onClick={(event) => {
|
||||
const scroller = panelRef.current?.closest<HTMLElement>('.assistant-sidebar__body')
|
||||
returnContext.current = { scrollTop: scroller?.scrollTop ?? 0, scrollLeft: scroller?.scrollLeft ?? 0, trigger: event.currentTarget }
|
||||
setSelected({ projectId, path: file.path })
|
||||
setActionError('')
|
||||
openDiff(file.path)
|
||||
}}
|
||||
title={`${file.previousPath ? `${file.previousPath} -> ` : ''}${file.path} (${file.status})`}
|
||||
type="button"
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>{file.path}</span>
|
||||
<span>{changeView === 'tree' ? file.path.split('/').at(-1) : file.path}</span>
|
||||
<small data-status={gitStatusLetter(file.status)} data-conflict={['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(file.status)} aria-label={file.status}>
|
||||
{gitStatusLetter(file.status)}
|
||||
</small>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
) }
|
||||
}))}
|
||||
{changedFiles.length > visibleChangeCount && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
@@ -382,17 +548,14 @@ export function WorkspaceFilesPanel({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<strong className="workspace-files__heading">
|
||||
{t('files.currentWorkspace')}
|
||||
</strong>
|
||||
{loadingPaths.has('') && !root ? (
|
||||
</WorkspaceGitTools>}
|
||||
</div>
|
||||
<div hidden={activeView !== 'files'}>
|
||||
{loadingPaths.has(browsedPath) && !root ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
{t('files.readingWorkspace')}
|
||||
</p>
|
||||
) : error && !root ? (
|
||||
<p className="assistant-sidebar__empty">{error}</p>
|
||||
) : root?.entries.length ? (
|
||||
) : errors[browsedPath] && !root ? null : root?.entries.length ? (
|
||||
<>
|
||||
<div className="workspace-files__tree">
|
||||
{root.entries.map((entry) => renderEntry(entry))}
|
||||
@@ -408,11 +571,28 @@ export function WorkspaceFilesPanel({
|
||||
{t('files.empty')}
|
||||
</p>
|
||||
)}
|
||||
{error && root && (
|
||||
<p className="workspace-files__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{renderDirectoryError(browsedPath)}
|
||||
</div>
|
||||
</div>
|
||||
{actionError && <p className="workspace-files__error" role="alert">{actionError}</p>}
|
||||
{menu && createPortal(<div ref={menuRef} className="workspace-files__menu" role="menu" aria-label={menu.entry.name} onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenu(true); return }
|
||||
const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
|
||||
const current = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const next = event.key === 'ArrowDown' ? (current + 1) % items.length : event.key === 'ArrowUp' ? (current - 1 + items.length) % items.length : event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : undefined
|
||||
if (next !== undefined) { event.preventDefault(); items[next]?.focus() }
|
||||
}}>
|
||||
{menu.entry.type === 'directory' && <button role="menuitem" type="button" onClick={() => { closeMenu(true); browse(menu.entry.path) }}><FolderOpen size={14} />{t('management.browse')}</button>}
|
||||
{(['rename', 'move', 'properties'] as const).map((kind) => {
|
||||
const Icon = { rename: Edit3, move: FolderInput, properties: Info }[kind]
|
||||
return <button key={kind} role="menuitem" type="button" onClick={() => { closeMenu(true); setDialog({ kind, path: menu.entry.path }) }}><Icon size={14} />{t(`management.${kind}`)}</button>
|
||||
})}
|
||||
<button role="menuitem" type="button" onClick={() => { closeMenu(true); void copyPath(menu.entry.path) }}><Copy size={14} />{t('sidebar.workspace.copyPath')}</button>
|
||||
{onOpenEntry && <button role="menuitem" type="button" onClick={() => { closeMenu(true); openEntry(menu.entry) }}><ExternalLink size={14} />{t('management.defaultOpen')}</button>}
|
||||
<div role="separator" />
|
||||
<button role="menuitem" type="button" className="danger-ghost" onClick={() => { closeMenu(true); setDialog({ kind: 'delete', path: menu.entry.path }) }}><Trash2 size={14} />{t('management.delete')}</button>
|
||||
</div>, document.body)}
|
||||
{dialog && <WorkspaceActionDialog key={projectId} projectId={projectId} rootPath={rootPath} {...dialog} onClose={() => setDialog(undefined)} onComplete={() => { setSelected(undefined); setCreateTarget(browsedPath); void refresh() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Download, FileText, GitBranch } from 'lucide-react'
|
||||
import type { WorkspaceManagementAction, WorkspaceManagementResult } from '../../shared/workspace-management-contracts'
|
||||
|
||||
type Result<K extends WorkspaceManagementResult['kind']> = Extract<WorkspaceManagementResult, { kind: K }>
|
||||
|
||||
export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewControl, children }: {
|
||||
projectId: string; refreshToken?: unknown; onRefresh: () => Promise<void>; viewControl: React.ReactNode; children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const [branches, setBranches] = useState<Result<'branches'>>()
|
||||
const [branchOpen, setBranchOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [history, setHistory] = useState<Result<'history'>>()
|
||||
const [height, setHeight] = useState(240)
|
||||
const [commit, setCommit] = useState<string>()
|
||||
const [files, setFiles] = useState<Result<'commitFiles'>>()
|
||||
const [patch, setPatch] = useState<Result<'commitDiff'>>()
|
||||
const [path, setPath] = useState<string>()
|
||||
const request = useRef(0)
|
||||
const branchRequest = useRef(0)
|
||||
const returnTrigger = useRef<HTMLElement | null>(null)
|
||||
const fileTrigger = useRef<HTMLElement | null>(null)
|
||||
const branchTrigger = useRef<HTMLButtonElement>(null)
|
||||
const diffBack = useRef<HTMLButtonElement>(null)
|
||||
const commitBack = useRef<HTMLButtonElement>(null)
|
||||
const action = (value: WorkspaceManagementAction): Promise<WorkspaceManagementResult> => window.goodbuddy.workspace.manage(projectId, value)
|
||||
useEffect(() => {
|
||||
const branchCounter = branchRequest
|
||||
const detailCounter = request
|
||||
const generation = ++branchRequest.current
|
||||
void window.goodbuddy.workspace.manage(projectId, { kind: 'branches' }).then((result) => {
|
||||
if (branchRequest.current === generation && result.kind === 'branches') setBranches(result)
|
||||
}).catch((reason: unknown) => { if (branchRequest.current === generation) setError(String(reason)) })
|
||||
return () => { branchCounter.current++; detailCounter.current++ }
|
||||
}, [projectId, refreshToken])
|
||||
useEffect(() => { if (path) diffBack.current?.focus(); else if (commit) commitBack.current?.focus() }, [path, commit])
|
||||
const run = async (value: WorkspaceManagementAction): Promise<void> => {
|
||||
if (busy) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await action(value)
|
||||
setBranchOpen(false); setHistory(undefined); setCommit(undefined); setFiles(undefined); setPath(undefined); setPatch(undefined)
|
||||
request.current++
|
||||
const result = await action({ kind: 'branches' })
|
||||
if (result.kind === 'branches') setBranches(result)
|
||||
await onRefresh()
|
||||
branchTrigger.current?.focus()
|
||||
} catch (reason) { setError(String(reason)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const loadHistory = async (more = false): Promise<void> => {
|
||||
const generation = ++request.current
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const result = await action({ kind: 'history', offset: more ? history?.commits.length ?? 0 : 0, head: more ? history?.head : undefined })
|
||||
if (generation === request.current && result.kind === 'history') setHistory({ ...result, commits: more ? [...(history?.commits ?? []), ...result.commits] : result.commits })
|
||||
} catch (reason) { if (generation === request.current) setError(String(reason)) }
|
||||
finally { if (generation === request.current) setBusy(false) }
|
||||
}
|
||||
const loadCommit = async (oid: string, selectedPath?: string): Promise<void> => {
|
||||
const generation = ++request.current
|
||||
setBusy(true); setError('')
|
||||
if (selectedPath) { setPath(selectedPath); setPatch(undefined) } else { setCommit(oid); setFiles(undefined) }
|
||||
try {
|
||||
const result = await action(selectedPath ? { kind: 'commitDiff', oid, path: selectedPath } : { kind: 'commitFiles', oid })
|
||||
if (generation !== request.current) return
|
||||
if (result.kind === 'commitFiles') setFiles(result)
|
||||
if (result.kind === 'commitDiff') setPatch(result)
|
||||
} catch (reason) { if (generation === request.current) setError(String(reason)) }
|
||||
finally { if (generation === request.current) setBusy(false) }
|
||||
}
|
||||
return <div className="workspace-git">
|
||||
{error && <p role="alert">{error}<button type="button" disabled={busy} onClick={() => { if (commit) void loadCommit(commit, path); else void loadHistory() }}>{t('files.retry')}</button></p>}
|
||||
<div hidden={Boolean(commit)} className="workspace-git__working">
|
||||
<div className="workspace-git__toolbar">
|
||||
<div className="workspace-git__repository-actions">
|
||||
<button ref={branchTrigger} className="model-button workspace-git__branch-trigger" type="button" aria-expanded={branchOpen} onClick={() => setBranchOpen(!branchOpen)}><GitBranch size={14} aria-hidden="true" /><span className="model-button__label" title={branches?.current}>{branches?.current || t('management.branch')}</span><ChevronDown size={14} aria-hidden="true" /></button>
|
||||
<button className="secondary-button workspace-git__fetch" type="button" title={t('management.fetch')} aria-label={t('management.fetch')} disabled={busy} onClick={() => void run({ kind: 'fetch' })}><Download size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
{viewControl}
|
||||
</div>
|
||||
{branchOpen && <div className="workspace-git__branches" onKeyDown={(event) => { if (event.key === 'Escape') { setBranchOpen(false); branchTrigger.current?.focus() } }}>
|
||||
<label>{t('management.searchBranch')}<input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} /></label>
|
||||
<button className="secondary-button" type="button" disabled={busy || !query.trim()} onClick={() => void run({ kind: 'createBranch', branch: query })}>{t('management.createBranch')}</button>
|
||||
<div>{branches?.branches.filter((branch) => branch.name.toLowerCase().includes(query.toLowerCase())).map((branch) => <button className="workspace-files__changed-row" type="button" key={`${branch.remote}:${branch.name}`} disabled={busy} aria-current={!branch.remote && branches.current === branch.name ? 'true' : undefined} onClick={() => void run({ kind: 'switchBranch', branch: branch.name, remote: branch.remote })}><GitBranch size={14} aria-hidden="true" /><span title={branch.name}>{branch.name}</span><small>{t(branch.remote ? 'management.remote' : 'management.local')}</small></button>)}</div>
|
||||
</div>}
|
||||
{children}
|
||||
<button className="workspace-files__changed-row workspace-git__history-toggle" type="button" aria-expanded={historyOpen} onClick={() => { setHistoryOpen(!historyOpen); if (!historyOpen && !history) void loadHistory() }}>{historyOpen ? <ChevronDown size={14} aria-hidden="true" /> : <ChevronRight size={14} aria-hidden="true" />}<span>{t('management.history')}</span></button>
|
||||
<section hidden={!historyOpen} className="workspace-git__history" style={{ height }}>
|
||||
<div role="separator" tabIndex={0} aria-label={t('management.resizeHistory')} aria-orientation="horizontal" aria-valuemin={120} aria-valuemax={600} aria-valuenow={height} onKeyDown={(event) => {
|
||||
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { event.preventDefault(); setHeight((current) => event.key === 'Home' ? 120 : event.key === 'End' ? 600 : Math.max(120, Math.min(600, current + (event.key === 'ArrowUp' ? 20 : -20)))) }
|
||||
}} onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.dataset.startY = String(event.clientY); event.currentTarget.dataset.startHeight = String(height) }} onPointerMove={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) setHeight(Math.max(120, Math.min(600, Number(event.currentTarget.dataset.startHeight) + Number(event.currentTarget.dataset.startY) - event.clientY)))
|
||||
}} onPointerUp={(event) => event.currentTarget.releasePointerCapture(event.pointerId)} />
|
||||
<div className="workspace-git__history-scroll">
|
||||
<button className="secondary-button" type="button" disabled={busy} onClick={() => void loadHistory()}>{t('sidebar.workspace.refresh')}</button>
|
||||
{history?.commits.map((item) => <button type="button" className="workspace-files__changed-row workspace-git__commit" key={item.oid} disabled={busy} onClick={(event) => { returnTrigger.current = event.currentTarget; void loadCommit(item.oid) }}>
|
||||
<strong title={item.subject}>{item.subject}</strong><span title={`${item.author} · ${new Date(item.time).toLocaleString()}`}>{item.author} · {new Date(item.time).toLocaleString()}</span><small title={`${item.oid} ${item.refs}`}>{item.oid.slice(0, 8)} {item.refs}</small>
|
||||
</button>)}
|
||||
{history?.commits.length === 0 && <p>{t('management.noCommits')}</p>}
|
||||
{history?.hasMore && <button className="secondary-button" type="button" disabled={busy} onClick={() => void loadHistory(true)}>{t('management.loadMore')}</button>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{commit && <section>
|
||||
<div hidden={Boolean(path)}>
|
||||
<button ref={commitBack} className="assistant-sidebar__back" type="button" onClick={() => { request.current++; setBusy(false); setCommit(undefined); setError(''); requestAnimationFrame(() => returnTrigger.current?.focus({ preventScroll: true })) }}><ChevronLeft size={14} aria-hidden="true" />{t('management.backHistory')}</button>
|
||||
<p>{commit.slice(0, 8)}</p>
|
||||
{files?.files.map((file) => <button className="workspace-files__changed-row" type="button" key={file.path} onClick={(event) => { fileTrigger.current = event.currentTarget; void loadCommit(commit, file.path) }}><FileText size={14} aria-hidden="true" /><span title={file.path}>{file.path}</span><small>{file.status}</small></button>)}
|
||||
</div>
|
||||
{path && <div><button ref={diffBack} className="assistant-sidebar__back" type="button" onClick={() => { request.current++; setBusy(false); setPath(undefined); setError(''); requestAnimationFrame(() => fileTrigger.current?.focus({ preventScroll: true })) }}><ChevronLeft size={14} aria-hidden="true" />{t('management.backCommit')}</button><strong>{path}</strong>
|
||||
<pre className="assistant-sidebar__diff workspace-files__diff">{patch?.patch.split('\n').map((line, index) => <span key={index} data-line={line.startsWith('+') ? 'added' : line.startsWith('-') ? 'deleted' : undefined}>{line}{'\n'}</span>)}</pre>
|
||||
{patch?.truncated && <p>{t('files.diffTruncated')}</p>}
|
||||
</div>}
|
||||
</section>}
|
||||
{busy && <p role="status">{t('files.reading')}</p>}
|
||||
</div>
|
||||
}
|
||||
@@ -2,6 +2,18 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
|
||||
|
||||
export const workspace = {
|
||||
management: {
|
||||
gitWorkspace: 'Git Workspace', more: 'More actions for {{name}}', browse: 'Browse directory', parent: 'Parent directory',
|
||||
createFile: 'New file', createDirectory: 'New directory', rename: 'Rename', move: 'Move', delete: 'Delete', properties: 'Properties',
|
||||
defaultOpen: 'Open with default application', name: 'Name', nameOnly: 'Name cannot contain path separators', destination: 'Destination directory',
|
||||
chooseDirectory: 'Choose directory', moveHelp: 'Choose a directory inside the current workspace. The file or folder keeps its name.',
|
||||
destinationOutside: 'Choose a directory inside the current workspace: {{root}}. Moving outside this workspace is not supported.',
|
||||
destinationUnavailable: 'The project directory is unavailable. Reopen the workspace and try again.',
|
||||
path: 'Path', type: 'Type', size: 'Size', modified: 'Modified', cancel: 'Cancel', deleteWarning: 'Permanently delete this entry and all directory contents. This cannot be undone.',
|
||||
branch: 'Branch', searchBranch: 'Search local or remote branches / new branch name', createBranch: 'Create and switch branch', fetch: 'Fetch', local: 'Local', remote: 'Remote',
|
||||
history: 'Commit history', resizeHistory: 'Resize commit history', noCommits: 'No commits on this branch', loadMore: 'Load more commits', backHistory: 'Back to history', backCommit: 'Back to commit files',
|
||||
changedView: 'Changed files view', list: 'List', tree: 'Tree'
|
||||
},
|
||||
builtInDefaultProject: {
|
||||
name: 'Default project',
|
||||
description: 'GoodBuddy default workspace'
|
||||
@@ -321,6 +333,12 @@ export const workspace = {
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
copyPath: 'Copy relative path',
|
||||
openFile: 'Open with default app',
|
||||
refreshing: 'Refreshing…',
|
||||
preview: 'Preview',
|
||||
source: 'Source',
|
||||
copyError: 'Unable to copy path.',
|
||||
back: 'Back to workspace',
|
||||
title: 'Files',
|
||||
projectTitle: 'Project workspace',
|
||||
@@ -350,6 +368,7 @@ export const workspace = {
|
||||
interact: 'Interact',
|
||||
interacting: 'Interacting',
|
||||
close: 'Close browser',
|
||||
viewport: 'Browser page',
|
||||
empty:
|
||||
'Enter an address to open a page. The same live view appears here when the Agent uses the browser.',
|
||||
toolbar: {
|
||||
@@ -359,7 +378,9 @@ export const workspace = {
|
||||
stopLoading: 'Stop loading',
|
||||
address: 'Browser address',
|
||||
addressPlaceholder: 'Enter a web address',
|
||||
go: 'Go'
|
||||
go: 'Go',
|
||||
fullscreen: 'Show browser fullscreen',
|
||||
exitFullscreen: 'Exit browser fullscreen'
|
||||
},
|
||||
statuses: {
|
||||
creating: 'Starting browser…',
|
||||
@@ -432,6 +453,13 @@ export const workspace = {
|
||||
taskList: 'Task list'
|
||||
},
|
||||
files: {
|
||||
view: 'Workspace view',
|
||||
filesView: 'Files',
|
||||
changesView: 'Uncommitted changes ({{count}})',
|
||||
backToChanges: 'Back to uncommitted changes',
|
||||
viewCurrentFile: 'View current file',
|
||||
noChanges: 'No uncommitted changes. Edit project files to review their differences here.',
|
||||
notRepository: 'This directory is not a Git repository. You can still browse project files in the Files view.',
|
||||
staged: 'Staged changes',
|
||||
unstaged: 'Working tree changes',
|
||||
noDiff: 'No textual differences (the file may be empty or only its status changed).',
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
export const workspace = {
|
||||
management: {
|
||||
gitWorkspace: 'Git 工作区', more: '{{name}} 的更多操作', browse: '浏览此目录', parent: '返回父目录',
|
||||
createFile: '新建文件', createDirectory: '新建目录', rename: '重命名', move: '移动', delete: '删除', properties: '属性',
|
||||
defaultOpen: '使用默认应用打开', name: '名称', nameOnly: '名称不能包含路径分隔符', destination: '目标目录',
|
||||
chooseDirectory: '选择目录', moveHelp: '请选择当前工作区内的目录,移动后保留文件或目录原名。',
|
||||
destinationOutside: '请选择当前工作区 {{root}} 内的目录,暂不支持移动到此工作区之外。',
|
||||
destinationUnavailable: '项目目录不可用,请重新打开工作区后重试。',
|
||||
path: '路径', type: '类型', size: '大小', modified: '修改时间', cancel: '取消', deleteWarning: '将永久删除该对象及目录内全部内容,无法撤销。',
|
||||
branch: '分支', searchBranch: '搜索本地或远程分支 / 新分支名称', createBranch: '创建并切换分支', fetch: 'Fetch', local: '本地', remote: '远程',
|
||||
history: '提交历史', resizeHistory: '调整提交历史高度', noCommits: '当前分支没有提交', loadMore: '加载更多提交', backHistory: '返回提交历史', backCommit: '返回提交文件',
|
||||
changedView: '更改显示方式', list: '列表', tree: '目录树'
|
||||
},
|
||||
builtInDefaultProject: {
|
||||
name: '默认项目',
|
||||
description: 'GoodBuddy 默认工作区'
|
||||
@@ -312,6 +324,12 @@ export const workspace = {
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
copyPath: '复制相对路径',
|
||||
openFile: '用默认应用打开',
|
||||
refreshing: '正在刷新…',
|
||||
preview: '预览',
|
||||
source: '源码',
|
||||
copyError: '复制路径失败。',
|
||||
back: '返回工作区',
|
||||
title: '工作区',
|
||||
projectTitle: '项目工作区',
|
||||
@@ -340,6 +358,7 @@ export const workspace = {
|
||||
interact: '交互',
|
||||
interacting: '交互中',
|
||||
close: '关闭浏览器',
|
||||
viewport: '浏览器页面',
|
||||
empty:
|
||||
'输入地址并前往可打开页面;Agent 打开网页后,同一会话的实时画面也会显示在这里。',
|
||||
toolbar: {
|
||||
@@ -349,7 +368,9 @@ export const workspace = {
|
||||
stopLoading: '停止加载',
|
||||
address: '浏览器地址',
|
||||
addressPlaceholder: '输入网址',
|
||||
go: '前往'
|
||||
go: '前往',
|
||||
fullscreen: '全屏显示浏览器',
|
||||
exitFullscreen: '退出浏览器全屏'
|
||||
},
|
||||
statuses: {
|
||||
creating: '正在启动浏览器…',
|
||||
@@ -422,6 +443,13 @@ export const workspace = {
|
||||
taskList: '任务列表'
|
||||
},
|
||||
files: {
|
||||
view: '工作区视图',
|
||||
filesView: '文件',
|
||||
changesView: '未提交更改({{count}})',
|
||||
backToChanges: '返回未提交更改',
|
||||
viewCurrentFile: '查看当前文件',
|
||||
noChanges: '没有未提交的更改。修改项目文件后,可在这里查看差异。',
|
||||
notRepository: '当前目录不是 Git 仓库。仍可在文件视图中浏览项目文件。',
|
||||
staged: '已暂存更改',
|
||||
unstaged: '工作树更改',
|
||||
noDiff: '没有文本差异(文件可能为空,或仅状态发生变化)。',
|
||||
|
||||
+270
-63
@@ -3822,14 +3822,22 @@ button > svg {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.assistant-sidebar--browser-fullscreen {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.assistant-sidebar--browser-fullscreen .workbar-shell__tab-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assistant-sidebar__resize-handle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -5px;
|
||||
left: -8px;
|
||||
display: none;
|
||||
width: 10px;
|
||||
width: 12px;
|
||||
padding: 0;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
@@ -3843,7 +3851,7 @@ button > svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 4px;
|
||||
left: 7px;
|
||||
width: 2px;
|
||||
background: transparent;
|
||||
content: '';
|
||||
@@ -4344,9 +4352,106 @@ button > svg {
|
||||
|
||||
.workspace-files__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.assistant-sidebar__body--browser {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-files__navigation[hidden],
|
||||
.workspace-files__list[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace-files__list:not([hidden]) {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workspace-files__project {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.workspace-files__breadcrumbs {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-files__breadcrumb-parts {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workspace-files__breadcrumb-parts button {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.workspace-files__breadcrumb-parts button + button::before {
|
||||
content: '/';
|
||||
margin-right: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workspace-files__breadcrumb-parts button:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workspace-files__actions {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-files__actions .icon-button,
|
||||
.workspace-files__breadcrumbs > .icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
.workspace-files__preview-actions {
|
||||
justify-content: flex-start;
|
||||
margin-block: var(--space-3);
|
||||
}
|
||||
|
||||
.workspace-files__refreshing {
|
||||
animation: workspace-refresh-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes workspace-refresh-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workspace-files__refreshing { animation: none; }
|
||||
}
|
||||
|
||||
.workspace-files__diff [data-line="added"] {
|
||||
color: var(--success);
|
||||
background: var(--success-subtle);
|
||||
@@ -4863,40 +4968,28 @@ button > svg {
|
||||
|
||||
.assistant-sidebar__browser {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser > header > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
gap: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: var(--space-3);
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
padding: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-tool-button,
|
||||
.assistant-sidebar__browser-go,
|
||||
.assistant-sidebar__browser-interact,
|
||||
.assistant-sidebar__browser-close {
|
||||
.assistant-sidebar__browser-go {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -4907,10 +5000,40 @@ button > svg {
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-address {
|
||||
min-width: 112px;
|
||||
min-width: 0;
|
||||
flex: 1 1 128px;
|
||||
}
|
||||
|
||||
@container (max-width: 280px) {
|
||||
.assistant-sidebar__browser-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-toolbar > button:nth-of-type(1),
|
||||
.assistant-sidebar__browser-toolbar > button:nth-of-type(2),
|
||||
.assistant-sidebar__browser-fullscreen-button {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-address,
|
||||
.assistant-sidebar__browser-go {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-address {
|
||||
flex-basis: calc(100% - var(--control-height) - var(--space-1));
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-go {
|
||||
width: var(--control-height);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-go > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-address input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -4930,16 +5053,13 @@ button > svg {
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-go,
|
||||
.assistant-sidebar__browser-interact,
|
||||
.assistant-sidebar__browser-close {
|
||||
.assistant-sidebar__browser-go {
|
||||
padding: 0 var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-tool-button:hover:not(:disabled),
|
||||
.assistant-sidebar__browser-go:hover:not(:disabled),
|
||||
.assistant-sidebar__browser-interact:hover:not(:disabled) {
|
||||
.assistant-sidebar__browser-go:hover:not(:disabled) {
|
||||
border-color: var(--accent-selected);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
@@ -4950,45 +5070,22 @@ button > svg {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-toolbar
|
||||
.assistant-sidebar__browser-close {
|
||||
padding: 0 var(--space-2);
|
||||
border-color: var(--danger-border);
|
||||
margin-left: var(--space-1);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-status {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-status--failed {
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-frame,
|
||||
.assistant-sidebar__browser-viewport,
|
||||
.assistant-sidebar__browser-placeholder {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-frame {
|
||||
display: block;
|
||||
height: auto;
|
||||
.assistant-sidebar__browser-viewport {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-placeholder {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -4996,6 +5093,8 @@ button > svg {
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
font-size: 10px;
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.assistant-sidebar__preview > header {
|
||||
@@ -5199,6 +5298,13 @@ button > svg {
|
||||
);
|
||||
}
|
||||
|
||||
.workspace-files__row[aria-current="true"],
|
||||
.workspace-files__changed-row[aria-current="true"] {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -11789,7 +11895,8 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.conversation-actions button {
|
||||
.conversation-actions button,
|
||||
.workspace-files__menu button {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
@@ -11803,7 +11910,8 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.conversation-actions button:hover {
|
||||
.conversation-actions button:hover,
|
||||
.workspace-files__menu button:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -16278,7 +16386,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
.assistant-sidebar__approval-actions button,
|
||||
.assistant-sidebar__badge,
|
||||
.assistant-sidebar__browser-placeholder,
|
||||
.assistant-sidebar__browser-status,
|
||||
.assistant-sidebar__diff,
|
||||
.assistant-sidebar__empty,
|
||||
.assistant-sidebar__import,
|
||||
@@ -17221,3 +17328,103 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.workspace-files__more {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--space-1);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
}
|
||||
.workspace-files__entry:hover .workspace-files__more,
|
||||
.workspace-files__entry:focus-within .workspace-files__more,
|
||||
.workspace-files__more[aria-expanded="true"] { opacity: 1; }
|
||||
.workspace-files__menu {
|
||||
position: fixed;
|
||||
z-index: calc(var(--z-dialog) - 1);
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 160px;
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px);
|
||||
overflow: auto;
|
||||
padding: var(--space-1);
|
||||
gap: var(--space-1);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
.workspace-files__menu button { min-height: 32px; justify-content: flex-start; font-size: var(--font-body); }
|
||||
.workspace-files__menu .danger-ghost { color: var(--danger); }
|
||||
.workspace-files__menu .danger-ghost:hover { color: var(--danger-strong); background: var(--danger-subtle); }
|
||||
.workspace-files__menu [role="separator"] { border-top: 1px solid var(--border-subtle); }
|
||||
.workspace-files__path { overflow-wrap: anywhere; }
|
||||
.workspace-files__directory-picker { display: grid; min-width: 0; gap: var(--space-2); }
|
||||
.workspace-files__directory-picker .remote-directory-picker__content { max-height: 240px; }
|
||||
.workspace-git__working { display: flex; flex-direction: column; min-height: 0; gap: var(--space-2); }
|
||||
.workspace-git__working[hidden] { display: none; }
|
||||
.workspace-files__view-switch { align-self: flex-start; max-width: 100%; }
|
||||
.workspace-files__view-switch > .segmented-control,
|
||||
.workspace-git__toolbar > .segmented-control {
|
||||
width: max-content;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.workspace-files__view-switch .segmented-control__option,
|
||||
.workspace-git__toolbar .segmented-control__option {
|
||||
min-height: 32px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
.workspace-files__view-switch .segmented-control__option--active,
|
||||
.workspace-git__toolbar .segmented-control__option--active {
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: none;
|
||||
}
|
||||
.workspace-git__toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.workspace-git__repository-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.workspace-git__branch-trigger {
|
||||
width: auto;
|
||||
max-width: 160px;
|
||||
height: 32px;
|
||||
flex: 0 1 auto;
|
||||
text-align: left;
|
||||
}
|
||||
.workspace-git__branch-trigger > svg { flex: 0 0 auto; }
|
||||
.workspace-git__fetch {
|
||||
width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
flex: 0 0 32px;
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
.workspace-git__branches { display: grid; min-width: 0; gap: var(--space-2); padding-bottom: var(--space-2); }
|
||||
.workspace-git__branches input { min-width: 0; width: 100%; }
|
||||
.workspace-git__history-toggle { margin-top: var(--space-2); font-weight: 650; }
|
||||
.workspace-git__branches > div { display: grid; max-height: 240px; overflow: auto; }
|
||||
.workspace-git__branches label, .workspace-git__commit { display: grid; text-align: left; }
|
||||
.workspace-git__history { display: flex; flex-direction: column; flex: 0 0 auto; min-height: 120px; max-height: 600px; border-top: 1px solid var(--border-default); }
|
||||
.workspace-git__history[hidden] { display: none; }
|
||||
.workspace-git__history > [role="separator"] { height: var(--space-2); flex: 0 0 auto; cursor: ns-resize; touch-action: none; }
|
||||
.workspace-git__history-scroll { overflow: auto; min-height: 0; }
|
||||
.workspace-git__commit { width: 100%; padding: var(--space-2); grid-template-columns: minmax(0, 1fr); gap: var(--space-1); border-bottom: 1px solid var(--border-subtle); }
|
||||
.workspace-git__commit strong, .workspace-git__commit small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-git__commit span, .workspace-git__commit small { color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
@media (hover: none), (pointer: coarse) { .workspace-files__more { opacity: 1; } }
|
||||
|
||||
+28
-2
@@ -1410,11 +1410,33 @@ export const browserStopRequestSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const browserInteractRequestSchema = browserStopRequestSchema
|
||||
export const browserBackRequestSchema = browserStopRequestSchema
|
||||
export const browserReloadRequestSchema = browserStopRequestSchema
|
||||
export const browserStopLoadingRequestSchema = browserStopRequestSchema
|
||||
|
||||
export const browserSetViewportRequestSchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema.optional(),
|
||||
bounds: z
|
||||
.object({
|
||||
x: z.number().int().nonnegative(),
|
||||
y: z.number().int().nonnegative(),
|
||||
width: z.number().int().positive().max(8_192),
|
||||
height: z.number().int().positive().max(8_192)
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) => Boolean(value.conversationId) === Boolean(value.bounds),
|
||||
'浏览器视口参数不完整'
|
||||
)
|
||||
|
||||
export type BrowserViewportBounds = NonNullable<
|
||||
z.infer<typeof browserSetViewportRequestSchema>['bounds']
|
||||
>
|
||||
|
||||
export const browserNavigateRequestSchema = z
|
||||
.object({
|
||||
conversationId: conversationIdSchema,
|
||||
@@ -1619,7 +1641,10 @@ export type DesktopApi = {
|
||||
back: (conversationId: string) => Promise<void>
|
||||
reload: (conversationId: string) => Promise<void>
|
||||
stopLoading: (conversationId: string) => Promise<void>
|
||||
interact: (conversationId: string) => Promise<void>
|
||||
setViewport: (
|
||||
conversationId?: string,
|
||||
bounds?: BrowserViewportBounds
|
||||
) => Promise<void>
|
||||
stop: (conversationId: string) => Promise<void>
|
||||
onState: (listener: (state: BrowserLiveState) => void) => () => void
|
||||
}
|
||||
@@ -1891,6 +1916,7 @@ export type DesktopApi = {
|
||||
) => () => void
|
||||
}
|
||||
workspace: {
|
||||
manage: (projectId: string, action: import('./workspace-management-contracts').WorkspaceManagementAction) => Promise<import('./workspace-management-contracts').WorkspaceManagementResult>
|
||||
getChanges: (projectId: string) => Promise<WorkspaceChanges>
|
||||
getFileDiff: (projectId: string, path: string) => Promise<WorkspaceChanges>
|
||||
listDirectory: (
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ipcChannels = {
|
||||
browserBack: 'browser:back',
|
||||
browserReload: 'browser:reload',
|
||||
browserStopLoading: 'browser:stop-loading',
|
||||
browserInteract: 'browser:interact',
|
||||
browserSetViewport: 'browser:set-viewport',
|
||||
browserStop: 'browser:stop',
|
||||
browserState: 'browser:state',
|
||||
terminalCreate: 'terminal:create',
|
||||
@@ -180,6 +180,7 @@ export const ipcChannels = {
|
||||
conversationQueueChanged: 'conversation-queue:changed',
|
||||
conversationQueueDispatch: 'conversation-queue:dispatch',
|
||||
workspaceChangesGet: 'workspace:changes:get',
|
||||
workspaceManage: 'workspace:manage',
|
||||
workspaceFileDiff: 'workspace:file:diff',
|
||||
workspaceDirectoryList: 'workspace:directory:list',
|
||||
workspaceFileRead: 'workspace:file:read',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { workspaceManagementActionSchema } from './workspace-management-contracts'
|
||||
import {
|
||||
acpFrameDirectionSchema,
|
||||
agentIdentifierSchema,
|
||||
@@ -572,6 +573,11 @@ const remoteWorkspaceReferenceFields = {
|
||||
generation: workspaceGenerationSchema
|
||||
} as const
|
||||
|
||||
export const remoteWorkspaceManageRequestSchema = z.object({
|
||||
...remoteWorkspaceReferenceFields,
|
||||
action: workspaceManagementActionSchema
|
||||
}).strict()
|
||||
|
||||
const remoteWorkspacePathFields = {
|
||||
...remoteWorkspaceReferenceFields,
|
||||
relativePath: remoteRelativePathSchema
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const pathSchema = z.string().min(1).max(4096).refine((value) =>
|
||||
!value.includes('\0') && !/^(?:[A-Za-z]:|[\\/])/.test(value) &&
|
||||
!value.split(/[\\/]/).some((part) => part === '..' || part.toLowerCase() === '.git'),
|
||||
'Expected a workspace relative path')
|
||||
const branchSchema = z.string().min(1).max(1024).refine((value) => !value.startsWith('-') && !value.includes('\0'))
|
||||
const oidSchema = z.string().regex(/^[a-f0-9]{40,64}$/)
|
||||
|
||||
export const workspaceManagementActionSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('createFile'), path: pathSchema }).strict(),
|
||||
z.object({ kind: z.literal('createDirectory'), path: pathSchema }).strict(),
|
||||
z.object({ kind: z.literal('move'), path: pathSchema, destination: pathSchema }).strict(),
|
||||
z.object({ kind: z.literal('delete'), path: pathSchema }).strict(),
|
||||
z.object({ kind: z.literal('properties'), path: pathSchema }).strict(),
|
||||
z.object({ kind: z.literal('branches') }).strict(),
|
||||
z.object({ kind: z.literal('switchBranch'), branch: branchSchema, remote: z.boolean().default(false) }).strict(),
|
||||
z.object({ kind: z.literal('createBranch'), branch: branchSchema }).strict(),
|
||||
z.object({ kind: z.literal('fetch') }).strict(),
|
||||
z.object({ kind: z.literal('history'), offset: z.number().int().min(0), head: oidSchema.optional() }).strict(),
|
||||
z.object({ kind: z.literal('commitFiles'), oid: oidSchema }).strict(),
|
||||
z.object({ kind: z.literal('commitDiff'), oid: oidSchema, path: pathSchema }).strict()
|
||||
])
|
||||
export const workspaceManagementRequestSchema = z.object({
|
||||
projectId: z.string().uuid(), action: workspaceManagementActionSchema
|
||||
}).strict()
|
||||
export const workspaceManagementResultSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('done') }).strict(),
|
||||
z.object({ kind: z.literal('properties'), path: z.string(), type: z.enum(['file', 'directory', 'other']), size: z.number(), modifiedAt: z.string() }).strict(),
|
||||
z.object({ kind: z.literal('branches'), current: z.string(), branches: z.array(z.object({ name: z.string(), remote: z.boolean() })) }).strict(),
|
||||
z.object({ kind: z.literal('history'), head: oidSchema.optional(), hasMore: z.boolean(), commits: z.array(z.object({ oid: oidSchema, subject: z.string(), author: z.string(), time: z.string(), refs: z.string() })) }).strict(),
|
||||
z.object({ kind: z.literal('commitFiles'), files: z.array(z.object({ path: z.string(), status: z.string(), previousPath: z.string().optional() })) }).strict(),
|
||||
z.object({ kind: z.literal('commitDiff'), patch: z.string(), truncated: z.boolean() }).strict()
|
||||
])
|
||||
export type WorkspaceManagementAction = z.infer<typeof workspaceManagementActionSchema>
|
||||
export type WorkspaceManagementResult = z.infer<typeof workspaceManagementResultSchema>
|
||||
Reference in New Issue
Block a user