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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions apps/desktop/e2e/sidebar-folder-drop.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/main/__tests__/task-entry-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>();
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<void>;
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({
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/app-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/project-management-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -45,6 +47,11 @@ export interface ProjectManagementService {
* comes from the dialog that opened it.
*/
add(options?: { select?: boolean; name?: string }): Promise<SelectedDirectoryActionResult>;
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 }>;
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
>;
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 13 additions & 9 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ function AppShellContent({
setUiLocalePreference: Dispatch<SetStateAction<UiLocalePreference>>;
}) {
const toastApi = useToast();
const { selectedHost } = taskEntry.selectors;
const {
renderCompleteConversation,
refreshMessages,
Expand Down Expand Up @@ -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, []);
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1276,6 +1275,11 @@ function AppShellContent({
? taskEntry.commands.openNewProject
: undefined
}
onDropProjectFolders={
taskEntry.selectors.canDropProjectFolders
? taskEntry.commands.addDroppedProjectFolders
: undefined
}
>
{SESSION_RAIL}
</SessionNavigationProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface SessionNavigationChromeInput {
* the heading then carries no + at all.
*/
onNewProject?: () => void;
onDropProjectFolders?: (files: File[]) => Promise<void>;
}

export interface SessionNavigationProviderProps extends SessionNavigationChromeInput {
Expand Down Expand Up @@ -284,6 +285,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
moveDropGroupKeys,
moveTargets,
onNewProject: props.onNewProject,
onDropProjectFolders: props.onDropProjectFolders,
}),
[
controller.layout.viewMode,
Expand All @@ -294,6 +296,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
controller.selectors.worktreeSessionIds,
props.onSelectSession,
props.onNewProject,
props.onDropProjectFolders,
moveDropGroupKeys,
moveTargets,
openRowId,
Expand Down
Loading