diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index ac3734fb0c..d8652f7234 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -53,6 +53,10 @@ "tests": 2, "electron": "Code Mode reads back Host settings through IPC; opening Settings commits a pending title rename to the Host, with workbar hide/restore asserted in that same Session." }, + "sidebar-folder-drop.spec.ts": { + "tests": 1, + "electron": "native directory drag input must retain its filesystem-backed File through Electron contextBridge and webUtils.getPathForFile before main and the Host can register the project" + }, "sidebar-project-reload.spec.ts": { "tests": 1, "electron": "rail grouping has to be rebuilt from persisted project state after a renderer reload" diff --git a/apps/desktop/e2e/sidebar-folder-drop.spec.ts b/apps/desktop/e2e/sidebar-folder-drop.spec.ts new file mode 100644 index 0000000000..96160da627 --- /dev/null +++ b/apps/desktop/e2e/sidebar-folder-drop.spec.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from './fixtures'; + +// Electron owns the native backing of dropped Files and their transfer through +// contextBridge to webUtils.getPathForFile. Mocked File or preload tests cannot +// expose loss of that backing; this journey feeds a real directory through CDP +// native drag input and reads the project admitted by the actual Host. +test('native folder drop registers the real directory as a project', async ({ projectSidebarWindow: page }) => { + const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-native-folder-drop-')); + const folderName = '文件夹 Project'; + const folder = join(temporaryRoot, folderName); + await mkdir(folder); + const canonicalFolder = await realpath(folder); + const cdp = await page.context().newCDPSession(page); + + try { + await page.keyboard.press('Escape'); + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + await sidebar.getByRole('radio', { name: '按项目', exact: true }).click(); + const dropZone = sidebar.locator('.maka-project-folder-drop-zone[data-maka-file-drop-target="true"]'); + await expect(dropZone).toBeVisible(); + + const bounds = await dropZone.boundingBox(); + if (!bounds) throw new Error('Project folder drop zone has no bounds'); + const input = { + x: bounds.x + bounds.width / 2, + y: bounds.y + Math.min(bounds.height / 2, 32), + data: { items: [], files: [folder], dragOperationsMask: 1 }, + }; + await cdp.send('Input.dispatchDragEvent', { type: 'dragEnter', ...input }); + await cdp.send('Input.dispatchDragEvent', { type: 'dragOver', ...input }); + await cdp.send('Input.dispatchDragEvent', { type: 'drop', ...input }); + + await expect.poll(() => page.evaluate(async (directory) => { + const catalog = await window.maka.newTasks.getCatalog(); + return catalog.hosts.flatMap((host) => { + if (host.profile.kind !== 'local' || host.readiness !== 'ready' || host.state !== 'available') return []; + return host.projects + .filter((project) => project.locations.some((location) => location.path === directory)) + .map((project) => project.name); + }); + }, canonicalFolder)).toEqual([folderName]); + } finally { + await cdp.detach(); + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts index 272d5ef27d..8d29fedc51 100644 --- a/apps/desktop/src/main/__tests__/project-management-service.test.ts +++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test, type TestContext } from 'node:test'; @@ -175,6 +175,36 @@ test('re-adding an archived project reports the archived project instead of fail } }); +test('dropped directories are validated before registration and archived projects require restoration', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-drop-')); + const regularFile = join(base, 'notes.txt'); + await writeFile(regularFile, 'not a directory'); + const catalog = createProjectCatalog(join(base, 'storage'), { createId: () => 'project-drop' }); + t.after(async () => { + catalog.close(); + await rm(base, { recursive: true, force: true }); + }); + const service = createProjectManagementService({ + capabilities: LOCAL_CAPABILITIES, + catalog: managementCatalog(catalog), + chooseDirectory: unexpected, + selection: { currentSelection: unexpected, setSelection: () => assert.fail('drop must not change Host selection') }, + }); + + assert.deepEqual(await service.addDroppedDirectory(regularFile), { ok: false, reason: 'not-directory' }); + for (const invalidPath of ['', 'relative', null, {}]) { + await assert.rejects(service.addDroppedDirectory(invalidPath), TypeError); + } + await assert.rejects(service.addDroppedDirectory(join(base, 'missing')), { code: 'ENOENT' }); + assert.deepEqual(await catalog.list(), []); + const added = await service.addDroppedDirectory(base); + assert.equal(added.ok && added.project.preferredPath, await realpath(base)); + await catalog.archive('project-drop'); + assert.deepEqual(await service.addDroppedDirectory(base), { + ok: false, reason: 'archived', projectId: 'project-drop', + }); +}); + test('can register a draft Project without changing the Host selection', async () => { let selected = false; const service = createProjectManagementService({ @@ -383,6 +413,7 @@ test('does not expose Client directory actions for a remote Host', async () => { }); await assert.rejects(() => service.add(), /registered on the Host/); + await assert.rejects(() => service.addDroppedDirectory(tmpdir()), /registered on the Host/); await assert.rejects(() => service.relink('remote'), /registered on the Host/); await assert.rejects(() => service.select(null), /requires a Project/); assert.equal(await service.pathFor('remote'), null); diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts index ab60ead2a0..13e3bf6bcd 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -211,6 +211,56 @@ afterEach(() => { cleanupFakeDom(); }); +it('adds a folder batch locally with one error, archived recovery, and a shared lock without changing the remote draft', async () => { + const { root } = installReactRenderer(); + const errors: unknown[] = []; + const calls: string[] = []; + const confirmation = deferred(); + const services = createFakeTaskEntryServices({ catalog: { + getCatalog: async () => ({ defaultProfileId: 'remote', hosts: [readyHost(), readyRemoteHost('remote-host')] }), + addDroppedProject: async (host, file) => { + assert.deepEqual(host, { profileId: 'local', hostId: 'host-local' }); + calls.push(file.name); + if (file.name === 'notes.txt') return { ok: false, reason: 'not-directory' }; + if (file.name === 'archived') return { ok: false, reason: 'archived', projectId: 'existing' }; + if (file.name === 'unreadable') throw new Error('ENOENT'); + return { ok: true, project: project(file.name) }; + }, + restoreProject: async (host, id) => { + calls.push(`restore:${host.profileId}:${id}`); + return { ok: true, project: { ...project(id), name: 'Existing name' } }; + }, + renameProject: async () => { assert.fail('Restoring must preserve the original name'); }, + } }); + await act(async () => renderController(root, services, errors, async () => { + calls.push('confirm'); + return confirmation.promise; + })); + const { target, draftKey } = controller().selectors; + assert.equal(target?.profileId, 'remote'); + assert.equal(controller().selectors.canDropProjectFolders, true); + let batch: Promise; + await act(async () => { + batch = controller().commands.addDroppedProjectFolders( + ['notes.txt', 'archived', 'unreadable', 'last'].map((name) => new File([], name)), + ); + await controller().commands.addDroppedProjectFolders([new File([], 'concurrent')]); + controller().commands.addProject(); + }); + assert.equal(controller().host.directoryHost, undefined, 'Picker shares the batch lock'); + assert.equal(controller().selectors.canDropProjectFolders, false); + await act(async () => { confirmation.resolve(true); await batch; }); + assert.equal(controller().selectors.canDropProjectFolders, true); + assert.deepEqual(calls, ['notes.txt', 'archived', 'confirm', 'restore:local:existing', 'unreadable', 'last']); + assert.deepEqual([controller().selectors.target, controller().selectors.draftKey], [target, draftKey]); + const copy = getShellCopy('en').projectActions; + assert.deepEqual(errors, [{ + title: copy.projectUpdateFailedTitle, + description: `notes.txt: ${copy.dropDirectoriesOnly}\nunreadable: ${copy.readPathFailedFallback}`, + profileId: 'local', + }]); +}); + describe('Session workspace archived-project recovery', () => { it('confirms use for this Session, restores original identity, then relocates without selecting a new-task Project', async () => { const harness = await sessionRecoveryHarness({ @@ -590,10 +640,12 @@ describe('useTaskEntryController', () => { }); assert.equal(addCalls, 1); assert.equal(controller().selectors.workspacePicker.pending, true); + assert.equal(controller().selectors.canDropProjectFolders, false); await act(async () => added.resolve({ ok: true, project: project('project-b') })); assert.equal(controller().selectors.target?.projectId, 'project-b'); assert.equal(controller().selectors.workspacePicker.pending, false); + assert.equal(controller().selectors.canDropProjectFolders, true); }); it('prompts to restore an archived Project and selects it through the Desktop adapter after confirmation', async () => { diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts index 7f06ae91cf..8a6fa89ca3 100644 --- a/apps/desktop/src/main/app-ipc-main.ts +++ b/apps/desktop/src/main/app-ipc-main.ts @@ -149,6 +149,8 @@ export function registerAppIpc( select: options?.select !== false, ...(options?.name === undefined ? {} : { name: requireProjectName(options.name) }), })); + targetIpc.handle('projects:addDroppedDirectory', (_event, path: unknown) => + deps.projectManagement.addDroppedDirectory(path)); handleReconnectableRead(targetIpc, 'projects:directoryRoots', () => deps.projectManagement.directoryRoots()); handleReconnectableRead(targetIpc, 'projects:listDirectory', (_event, input: unknown) => diff --git a/apps/desktop/src/main/project-management-service.ts b/apps/desktop/src/main/project-management-service.ts index 08187e0bb5..cebcd08d14 100644 --- a/apps/desktop/src/main/project-management-service.ts +++ b/apps/desktop/src/main/project-management-service.ts @@ -17,6 +17,8 @@ * under the License. */ +import { stat } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; import { findProjectByIdentity, type ProjectRecord } from '@maka/core/project'; import type { DesktopProjectCapabilities, @@ -45,6 +47,11 @@ export interface ProjectManagementService { * comes from the dialog that opened it. */ add(options?: { select?: boolean; name?: string }): Promise; + addDroppedDirectory(path: unknown): Promise< + | { ok: true; project: ProjectRecord } + | { ok: false; reason: 'not-directory' } + | { ok: false; reason: 'archived'; projectId: string } + >; select( projectId: unknown, ): Promise<{ project: ProjectRecord | null; path: string }>; @@ -146,6 +153,19 @@ export function createProjectManagementService(deps: { return { ok: true, project: selected, path: selected.preferredPath }; }, + async addDroppedDirectory(path) { + requireLocalDirectoryActions(deps); + if (typeof path !== 'string' || !isAbsolute(path)) { + throw new TypeError('Invalid dropped project directory.'); + } + if (!(await stat(path)).isDirectory()) return { ok: false, reason: 'not-directory' }; + const project = await deps.catalog.register(path); + if (project.archivedAt !== undefined) { + return { ok: false, reason: 'archived', projectId: project.id }; + } + return { ok: true, project: requireSelectableProject(project) }; + }, + async select(projectId) { if (projectId === null) { if (!deps.capabilities.selectNoProject) { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 578dde8aa0..d2f9a91234 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1039,6 +1039,11 @@ export interface MakaBridge { | { ok: false; reason: 'cancelled' } | { ok: false; reason: 'archived'; projectId: string } >; + addDroppedProject(host: DesktopNewTaskHostRef, file: File): Promise< + | { ok: true; project: ProjectRecord } + | { ok: false; reason: 'not-directory' } + | { ok: false; reason: 'archived'; projectId: string } + >; restoreProject(host: DesktopNewTaskHostRef, projectId: string): Promise< { ok: true; project: ProjectRecord } | { ok: false; reason: 'cancelled' } >; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index a46dbc0a50..6e76f8bf9f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1996,6 +1996,20 @@ const makaBridge = { | { ok: false; reason: 'archived'; projectId: string }; return result.ok ? { ok: true as const, project: result.project } : result; }, + async addDroppedProject(host: DesktopNewTaskHostRef, file: File) { + let path: string; + try { + path = webUtils.getPathForFile(file); + } catch { + return { ok: false as const, reason: 'not-directory' as const }; + } + if (!path) return { ok: false as const, reason: 'not-directory' as const }; + return invokeWhenReady( + 'projects:addDroppedDirectory', + await runtimeHostScope(host), + path, + ); + }, async restoreProject(host: DesktopNewTaskHostRef, projectId: string) { const project = await ipcRenderer.invoke( 'projects:restore', diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 8950e2ff64..29c14ff6e6 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -229,6 +229,7 @@ function AppShellContent({ setUiLocalePreference: Dispatch>; }) { const toastApi = useToast(); + const { selectedHost } = taskEntry.selectors; const { renderCompleteConversation, refreshMessages, @@ -291,7 +292,7 @@ function AppShellContent({ ? (activeSession?.profileKind === 'local' ? activeSession.runtimeHostId : undefined) - : (taskEntry.selectors.selectedHost?.kind === 'local' + : (selectedHost?.kind === 'local' ? taskEntry.selectors.target?.hostId : undefined); const composerStaging = useMemo(Conversation.createComposerStagingCommands, []); @@ -318,12 +319,10 @@ function AppShellContent({ sessionId: ownerActiveId, disabled: sharedSessionActive, }); - const newTaskHost = taskEntry.selectors.selectedHost - ? { - profileId: taskEntry.selectors.selectedHost.profileId, - hostId: taskEntry.selectors.selectedHost.hostId, - } - : undefined; + const newTaskHost = selectedHost && { + profileId: selectedHost.profileId, + hostId: selectedHost.hostId, + }; const newTaskConnections = useShellConnections({ toastApi, uiLocale, @@ -412,7 +411,7 @@ function AppShellContent({ */ const newSessionPermissionMode = newTaskSettings.permissionChoice ?? - taskEntry.selectors.selectedHost?.chatDefaults.permissionMode ?? + selectedHost?.chatDefaults.permissionMode ?? 'bypass'; // Persisted composer defaults seed the empty-state model, project path, and // recent workspace history so the home view is populated before the async @@ -453,7 +452,7 @@ function AppShellContent({ : taskEntry.selectors.selectedProfileId; const composerProfileName = activeId ? activeSession?.profileName - : taskEntry.selectors.selectedHost?.name; + : selectedHost?.name; const modelSettingsOwnsComposerHost = composerProfileId !== undefined && composerProfileId === taskEntry.selectors.defaultProfileId; @@ -1276,6 +1275,11 @@ function AppShellContent({ ? taskEntry.commands.openNewProject : undefined } + onDropProjectFolders={ + taskEntry.selectors.canDropProjectFolders + ? taskEntry.commands.addDroppedProjectFolders + : undefined + } > {SESSION_RAIL} diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx index b838eff9d7..c14994937c 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx @@ -89,6 +89,7 @@ export interface SessionNavigationChromeInput { * the heading then carries no + at all. */ onNewProject?: () => void; + onDropProjectFolders?: (files: File[]) => Promise; } export interface SessionNavigationProviderProps extends SessionNavigationChromeInput { @@ -284,6 +285,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) moveDropGroupKeys, moveTargets, onNewProject: props.onNewProject, + onDropProjectFolders: props.onDropProjectFolders, }), [ controller.layout.viewMode, @@ -294,6 +296,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) controller.selectors.worktreeSessionIds, props.onSelectSession, props.onNewProject, + props.onDropProjectFolders, moveDropGroupKeys, moveTargets, openRowId, diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts index 24d62dd9e9..30c0151179 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -93,6 +93,7 @@ export interface TaskEntryControllerSelectors { readonly workspacePicker: WorkspacePickerModel; readonly sessionWorkspaceRecovery?: SessionWorkspaceRecoveryRequest; readonly canAddProject: boolean; + readonly canDropProjectFolders: boolean; /** Every ready Host's Projects, with the owning Host retained as identity. */ readonly projectScopes: readonly TaskEntryProjectScope[]; } @@ -107,6 +108,7 @@ export interface TaskEntryControllerCommands { relinkProject(projectKey: string): Promise; /** `name` is what the New project dialog collected, when there was one. */ addProject(name?: string): void; + addDroppedProjectFolders(files: File[]): Promise; openNewProject(): void; chooseProjectForProfile(profileId: string): Promise; openSessionWorkspaceRecovery(sessionId: string): void; @@ -428,6 +430,53 @@ export function useTaskEntryController( } }, [copy.archivedProjectCancel, copy.archivedProjectDescription, copy.archivedProjectRestore, copy.archivedProjectTitle, copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, input.confirm, locale, refreshAfterProjectMutation, reportError, service, toast]); + const addDroppedProjectFolders = useCallback(async (files: File[]): Promise => { + if (!files.length || projectMutationPendingRef.current || !localHost?.capabilities.chooseClientDirectory) return; + // OS drops always belong to the local Host, even while a remote task is open. + const hostRef = { profileId: localHost.profile.id, hostId: localHost.hostId }; + projectMutationPendingRef.current = true; + setPending(true); + const failures: string[] = []; + try { + for (const file of files) { + try { + await resolveProjectRegistration({ + register: async () => { + const result = await service.addDroppedProject(hostRef, file); + if (!result.ok && result.reason === 'not-directory') { + failures.push(`${file.name}: ${copy.dropDirectoriesOnly}`); + return { ok: false, reason: 'cancelled' }; + } + return result; + }, + confirm: (onConfirm) => (input.confirm ?? toast.confirm)({ + onConfirm, + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, + }), + restore: (projectId) => service.restoreProject(hostRef, projectId), + isCurrent: () => true, + }); + } catch (cause) { + failures.push(`${file.name}: ${localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale)}`); + } + } + await refresh(); + } catch (cause) { + failures.push(localizedShellErrorMessage(cause, copy.projectUpdateFailedFallback, locale)); + } finally { + projectMutationPendingRef.current = false; + setPending(false); + } + if (failures.length) reportError({ + title: copy.projectUpdateFailedTitle, + description: failures.join('\n'), + profileId: hostRef.profileId, + }); + }, [copy, input.confirm, localHost, locale, refresh, reportError, service, toast]); + const chooseProjectForProfile = useCallback(async (profileId: string): Promise => { let next: TaskEntryCatalog | undefined; try { @@ -793,6 +842,7 @@ export function useTaskEntryController( ); }), addProject: addSelectedProject, + addDroppedProjectFolders, openNewProject, chooseProjectForProfile, openSessionWorkspaceRecovery, @@ -825,11 +875,15 @@ export function useTaskEntryController( (selectedHost.capabilities.chooseClientDirectory || selectedHost.capabilities.chooseHostDirectory), ), + canDropProjectFolders: !pending && Boolean(localHost?.capabilities.chooseClientDirectory), projectScopes, }, }), [ acceptRegisteredProject, addSelectedProject, + addDroppedProjectFolders, + localHost, + pending, newProjectDialog, openNewProject, addSessionWorkspace, diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts index b11719b041..00b201cedf 100644 --- a/apps/desktop/src/renderer/features/task-entry/ports.ts +++ b/apps/desktop/src/renderer/features/task-entry/ports.ts @@ -115,6 +115,9 @@ export interface TaskEntryCatalogService { getCatalog(): Promise; subscribeChanges(handler: () => void): TaskEntryUnsubscribe; addProject(host: TaskEntryHostRef, name?: string): Promise; + addDroppedProject(host: TaskEntryHostRef, file: File): Promise< + TaskEntryProjectMutationResult | { readonly ok: false; readonly reason: 'not-directory' } + >; restoreProject(host: TaskEntryHostRef, projectId: string): Promise; relinkProject( host: TaskEntryHostRef, diff --git a/apps/desktop/src/renderer/features/task-entry/testing.ts b/apps/desktop/src/renderer/features/task-entry/testing.ts index bf7f7f6cef..9fdf7cab4c 100644 --- a/apps/desktop/src/renderer/features/task-entry/testing.ts +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -54,6 +54,7 @@ export function createFakeTaskEntryServices( getCatalog: async () => ({ defaultProfileId: 'local', hosts: [] }), subscribeChanges: noopSubscription, addProject: async () => ({ ok: false, reason: 'cancelled' }), + addDroppedProject: async () => ({ ok: false, reason: 'not-directory' }), restoreProject: async () => ({ ok: false, reason: 'cancelled' }), relinkProject: async () => ({ ok: false, reason: 'cancelled' }), renameProject: async () => undefined, diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx index 0651c32f75..1668611eb7 100644 --- a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx +++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx @@ -80,6 +80,7 @@ const EMPTY_CONTROLLER: TaskEntryController = { async restoreProject() {}, async relinkProject() {}, addProject() {}, + async addDroppedProjectFolders() {}, openNewProject() {}, async chooseProjectForProfile() {}, openSessionWorkspaceRecovery() {}, @@ -107,6 +108,7 @@ const EMPTY_CONTROLLER: TaskEntryController = { usesDefaultHost: true, workspacePicker: EMPTY_WORKSPACE_PICKER, canAddProject: false, + canDropProjectFolders: false, projectScopes: [], }, }; @@ -141,6 +143,7 @@ function createTaskEntryOwner(): TaskEntryOwner & { relinkProject: (projectKey: string) => current.commands.relinkProject(projectKey), addProject: (name?: string) => current.commands.addProject(name), + addDroppedProjectFolders: (files: File[]) => current.commands.addDroppedProjectFolders(files), openNewProject: () => current.commands.openNewProject(), chooseProjectForProfile: (profileId: string) => current.commands.chooseProjectForProfile(profileId), @@ -270,6 +273,7 @@ function sameShellSelectors( previous.defaultProfileId === next.defaultProfileId && previous.usesDefaultHost === next.usesDefaultHost && previous.canAddProject === next.canAddProject && + previous.canDropProjectFolders === next.canDropProjectFolders && sameProjectScopes(previous.projectScopes, next.projectScopes) ); } diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index cee766bf0f..4fa300f3d0 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -187,6 +187,7 @@ type ShellCopy = { readPathFailedFallback: string; selectDirectoryFailedTitle: string; selectedPathUnreadable: string; + dropDirectoriesOnly: string; directorySwitchedTitle: string; projectUpdateFailedTitle: string; projectUpdateFailedFallback: string; @@ -798,6 +799,7 @@ const shellCopyByLocaleZhCn: ShellCopy = { readPathFailedFallback: '项目路径暂时无法读取,请稍后重试。', selectDirectoryFailedTitle: '选择工作目录失败', selectedPathUnreadable: '所选路径不存在或不可读。', + dropDirectoriesOnly: '只能拖入文件夹来添加项目。', directorySwitchedTitle: '已切换工作目录', projectUpdateFailedTitle: '项目操作失败', projectUpdateFailedFallback: '暂时无法更新项目,请稍后重试。', @@ -1335,6 +1337,7 @@ const shellCopyByLocaleZhTw: ShellCopy = { readPathFailedFallback: '專案路徑暫時無法讀取,請稍後重試。', selectDirectoryFailedTitle: '選擇工作目錄失敗', selectedPathUnreadable: '所選路徑不存在或不可讀。', + dropDirectoriesOnly: '只能拖入資料夾來新增專案。', directorySwitchedTitle: '已切換工作目錄', projectUpdateFailedTitle: '專案操作失敗', projectUpdateFailedFallback: '暫時無法更新專案,請稍後重試。', @@ -1874,6 +1877,7 @@ const shellCopyByLocaleEn: ShellCopy = { readPathFailedFallback: 'The project path is temporarily unavailable. Try again later.', selectDirectoryFailedTitle: 'Could not select working directory', selectedPathUnreadable: 'The selected path does not exist or cannot be read.', + dropDirectoriesOnly: 'Only folders can be added as projects.', directorySwitchedTitle: 'Working directory changed', projectUpdateFailedTitle: 'Could not update project', projectUpdateFailedFallback: 'The project could not be updated. Try again later.', diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 791198e764..83b9da3aae 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -54,6 +54,29 @@ box-sizing: border-box; } +/* Include the scroll area's blank space when receiving project folders. */ +.astryx-side-nav.maka-session-panel > div:has(> .maka-project-folder-drop-zone) { + display: flex; + flex-direction: column; +} + +.maka-project-folder-drop-zone { + flex: 1; + min-width: 0; + border-radius: var(--radius-control); +} + +.maka-project-folder-drop-zone[data-folder-drop-active="true"] { + outline: 2px solid var(--accent-solid); + outline-offset: -2px; +} + +.maka-project-folder-drop-hint { + padding: var(--space-2); + color: var(--muted-foreground); + pointer-events: none; +} + /* * One type size for the whole rail. SideNavSection draws its title from the * `supporting` tier (12px), one step below the session rows it labels; against diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 70538bda7a..bb55fc70ae 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 344 files — blocker 0, reimplementation 0, polish 5, aligned 339. +**Totals:** 345 files — blocker 0, reimplementation 0, polish 5, aligned 340. ## Exclusions (explicit) @@ -340,6 +340,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/primitives/module-page.tsx` | primitive | Dialog, DialogHeader, HStack, Heading, Layout, LayoutContent, LayoutFooter, LayoutHeader, ListItem, StackItem, Text, VStack | aligned — uses Astryx (Dialog, DialogHeader, HStack, Heading, Layout, LayoutContent, LayoutFooter, LayoutHeader) | aligned | | `packages/ui/src/primitives/stat-tile.tsx` | primitive | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/progress-card.tsx` | ui-composition | IconButton | aligned — uses Astryx (IconButton) | aligned | +| `packages/ui/src/project-folder-drop-zone.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/prompt-anchor-rail.tsx` | ui-composition | Button, HoverCard | aligned — uses Astryx (Button, HoverCard) | aligned | | `packages/ui/src/prompt-suggestion.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/provider-retry-notice.tsx` | ui-composition | Banner | aligned — uses Astryx (Banner) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 5c90041abb..eccdabb552 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -309,6 +309,7 @@ packages/ui/src/primitives/chat.tsx packages/ui/src/primitives/module-page.tsx packages/ui/src/primitives/stat-tile.tsx packages/ui/src/progress-card.tsx +packages/ui/src/project-folder-drop-zone.tsx packages/ui/src/prompt-anchor-rail.tsx packages/ui/src/prompt-suggestion.tsx packages/ui/src/provider-retry-notice.tsx diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 77dafb525b..b530119b8f 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -414,6 +414,7 @@ export interface ConversationCopy { recent: string; /** Project-sort section title, sibling of `pinned` (SideNavSection). */ projects: string; + dropProjectFolders: string; groupByTime: string; groupByProject: string; groupingAriaLabel: string; @@ -557,6 +558,7 @@ const conversationCopyZhCn: ConversationCopy = { revisionVersionsAriaLabel: '任务版本', revisionVersion: (current, total) => `版本 ${current} / ${total}`, previousRevision: '查看上一版本', nextRevision: '查看下一版本', }, sessions: { + dropProjectFolders: '松开以添加项目', untitled: '新建任务', status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', aborted: '已中止' }, blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' }, @@ -681,6 +683,7 @@ const conversationCopyZhTw: ConversationCopy = { revisionVersionsAriaLabel: '任務版本', revisionVersion: (current, total) => `版本 ${current} / ${total}`, previousRevision: '檢視上一版本', nextRevision: '檢視下一版本', }, sessions: { + dropProjectFolders: '放開以新增專案', untitled: '建立任務', status: { active: '可繼續', running: '進行中', waiting_for_user: '等你確認', blocked: '需要處理', aborted: '已中止' }, blockedReason: { NO_REAL_CONNECTION: '等待設定可用模型連線', auth: '需要重新登入', permission_required: '等待權限確認', tool_failed: '工具呼叫失敗', unknown: '執行中斷,可重試' }, @@ -804,6 +807,7 @@ const conversationCopyEn: ConversationCopy = { revisionVersionsAriaLabel: 'Task versions', revisionVersion: (current, total) => `Version ${current} of ${total}`, previousRevision: 'View previous version', nextRevision: 'View next version', }, sessions: { + dropProjectFolders: 'Drop to add projects', untitled: 'New task', status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', aborted: 'Stopped' }, blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' }, diff --git a/packages/ui/src/project-folder-drop-zone.tsx b/packages/ui/src/project-folder-drop-zone.tsx new file mode 100644 index 0000000000..e97619b6f1 --- /dev/null +++ b/packages/ui/src/project-folder-drop-zone.tsx @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState, type DragEvent, type ReactNode } from 'react'; +import { getConversationCopy } from './conversation-copy.js'; +import { useUiLocale } from './locale-context.js'; +import { useSessionRailData } from './session-rail-context.js'; + +export function ProjectFolderDropZone({ children }: { children: ReactNode }) { + const rail = useSessionRailData(); + const copy = getConversationCopy(useUiLocale()).sessions; + const enabled = rail.groupVariant === 'project' && rail.onDropProjectFolders !== undefined; + const [dragging, setDragging] = useState(false); + const active = enabled && dragging; + + function captureFileDrag(event: DragEvent) { + const types = Array.from(event.dataTransfer.types); + if (!enabled || types.includes('application/x-maka-session')) return false; + // Keep the window drop guard's non-file blocking inside this file target. + event.preventDefault(); + event.stopPropagation(); + return types.includes('Files'); + } + + function handleDragOver(event: DragEvent) { + if (!captureFileDrag(event)) return; + event.dataTransfer.dropEffect = 'copy'; + setDragging(true); + } + + return ( +
{ + if (!event.relatedTarget || !event.currentTarget.contains(event.relatedTarget as Node)) { + setDragging(false); + } + }} + onDropCapture={(event) => { + if (!captureFileDrag(event)) return; + setDragging(false); + void rail.onDropProjectFolders?.(Array.from(event.dataTransfer.files)); + }} + > + {children} + {active && ( +
{copy.dropProjectFolders}
+ )} +
+ ); +} diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index a23ccf29c8..33478e3161 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -31,6 +31,7 @@ import { type RefObject, } from 'react'; import { useMountedRef } from './use-mounted-ref.js'; +import { ProjectFolderDropZone } from './project-folder-drop-zone.js'; import type { ProjectRecord } from '@maka/core/project'; import type { SessionSummary } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; @@ -423,15 +424,17 @@ export function SessionHistoryList() { // extra information for anyone hearing it. It is scroll content and a key // handler, nothing an assistive tech user needs to be told about separately. return ( -
- -
+ +
+ +
+
); } diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx index 156440634b..421fb7c73c 100644 --- a/packages/ui/src/session-rail-context.tsx +++ b/packages/ui/src/session-rail-context.tsx @@ -103,6 +103,8 @@ export interface SessionRailData { * heading, and absent when the shell has no host that can make one. */ onNewProject?: () => void; + /** Register local folders dropped onto the project list. */ + onDropProjectFolders?: (files: File[]) => void | Promise; /** Opaque Project ids whose Host can choose a replacement client directory. */ relinkableProjectIds?: ReadonlySet; } diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 584895d9e7..74317b03d9 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -19,7 +19,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import type { ProjectRecord } from '@maka/core/project'; import type { SessionBlockedReason, SessionStatus, SessionSummary } from '@maka/core/session'; import { SessionRail, type SessionRailStoryProps } from './session-rail-harness.js'; @@ -552,6 +552,9 @@ export const PinnedAndRecentSections: Story = { }, }; +const registerDroppedFolders = fn(); +const moveDroppedSession = fn(); + // Real path: group-by-project — collapsible project rows, sessions on the // project row's left edge so both titles and hover fills share one x, worktree // mark + project actions, with local, remote and WSL execution locations. @@ -668,6 +671,12 @@ export const ProjectGroups: Story = { onRelink: noop, }, })} + onDropProjectFolders={registerDroppedFolders} + rowActions={{ ...rowActions, onMoveToProject: moveDroppedSession }} + moveDropGroupKeys={new Set([`project:${maka.id}`])} + moveTargets={(sessionId) => sessionId === 'proj-loose' + ? [{ groupKey: `project:${maka.id}`, projectId: maka.id, name: maka.name }] + : []} /> ); @@ -679,6 +688,40 @@ export const ProjectGroups: Story = { '[data-project-id="project:project-maka"]', ); if (!projectRow) throw new Error('project row is missing'); + // Native capture must block external drops before the task row's handler; + // Linkedom dispatches these phases in a different order. + registerDroppedFolders.mockClear(); + moveDroppedSession.mockClear(); + const source = canvasElement.querySelector('[data-session-id="proj-loose"]'); + if (!source) throw new Error('draggable task is missing'); + const drag = (type: string, dataTransfer: DataTransfer, target = projectRow) => { + const event = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer }); + target.dispatchEvent(event); + return event; + }; + const session = new DataTransfer(); + drag('dragstart', session, source); + try { + for (const type of ['text/plain', 'text/uri-list']) { + const text = new DataTransfer(); + text.setData(type, 'https://example.com/'); + expect(drag('dragover', text).defaultPrevented).toBe(true); + expect(drag('drop', text).defaultPrevented).toBe(true); + } + expect(registerDroppedFolders).not.toHaveBeenCalled(); + expect(moveDroppedSession).not.toHaveBeenCalled(); + const folder = new File([], 'new-project'); + const files = new DataTransfer(); + files.items.add(folder); + drag('drop', files); + expect(registerDroppedFolders.mock.calls).toEqual([[[folder]]]); + expect(moveDroppedSession).not.toHaveBeenCalled(); + drag('drop', session); + expect(moveDroppedSession.mock.calls).toEqual([['proj-loose', 'project-maka']]); + expect(registerDroppedFolders).toHaveBeenCalledTimes(1); + } finally { + drag('dragend', session, source); + } const navigation = projectRow.querySelector( 'button[aria-controls]:not([aria-haspopup="menu"])', ); diff --git a/packages/ui/stories/session-rail-harness.tsx b/packages/ui/stories/session-rail-harness.tsx index 98264302ab..64cfac6dbe 100644 --- a/packages/ui/stories/session-rail-harness.tsx +++ b/packages/ui/stories/session-rail-harness.tsx @@ -65,6 +65,9 @@ export function SessionRail(props: SessionRailStoryProps) { onSelectSession: props.onSelectSession ?? (() => undefined), rowActions: props.rowActions, projectActions: props.projectActions, + moveDropGroupKeys: props.moveDropGroupKeys, + moveTargets: props.moveTargets, + onDropProjectFolders: props.onDropProjectFolders, }; const chrome: SessionRailChrome = { collapsed: props.collapsed ?? false,