Skip to content
Closed
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
29 changes: 29 additions & 0 deletions apps/desktop/src/main/__tests__/project-add-restore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,21 @@ async function submitNamedProject(repetitions = 1) {
await submitProjectName(repetitions);
}

async function submitBlankProject() {
await click('New project');
const form = document.querySelector('#maka-new-project-form');
assert.ok(form);
const button = document.querySelector<HTMLButtonElement>(
'button[type="submit"][form="maka-new-project-form"]',
);
assert.ok(button, 'choose-folder stays available with an empty name');
assert.equal(button.disabled, false);
await act(async () => {
form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true }));
});
await flushFrames();
}

async function submitProjectName(repetitions = 1) {
const input = document.querySelector('input');
assert.ok(input);
Expand Down Expand Up @@ -525,6 +540,20 @@ test('remote registration rejects same-tick duplicate submissions without invali
assert.deepEqual(accepted, [restored]);
});

test('Settings Add with a blank name still opens the folder picker', async () => {
const harness = installRenderer();
const names: unknown[] = [];
bridge({
add: async (_target: DesktopRuntimeHostRef, options?: { name?: string }) => {
names.push(options);
return { ok: true, project: restored };
},
});
await harness.render(createElement(components.SettingsFixture));
await submitBlankProject();
assert.deepEqual(names, [undefined]);
});

test('Settings Add stays single-flight while the folder picker is pending', async () => {
const harness = installRenderer();
const addition = deferred<{ ok: true; project: ProjectRecord }>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export interface AddSessionWorkspaceInput {
sessionId: string;
profileId: string;
host: TaskEntryHostRef;
name: string;
/** Custom name from the New project dialog; omitted to use the folder name. */
name?: string;
request?: SessionWorkspaceRecoveryRequest;
}

Expand Down Expand Up @@ -148,7 +149,7 @@ export function useSessionWorkspaceRecovery({
let projectId: string;
if (adding) {
const result = await resolveProjectRegistration({
register: () => service.addProject(input.host, input.name),
register: () => service.addProject(input.host, input.name?.trim() || undefined),
confirm: (onConfirm) => (confirm ?? toast.confirm)({
onConfirm,
title: projectCopy.archivedProjectTitle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,18 +339,19 @@ export function useTaskEntryController(

const addProjectForHost = useCallback(async (host: ReadyTaskEntryHost, name?: string): Promise<void> => {
if (projectMutationPendingRef.current) return;
const customName = name?.trim() || undefined;
if (host.capabilities.chooseHostDirectory) {
directoryOpenerRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
setDirectoryHost(directoryHandoffForHost(host, name));
setDirectoryHost(directoryHandoffForHost(host, customName));
return;
}
if (!host.capabilities.chooseClientDirectory) return;
projectMutationPendingRef.current = true;
setPending(true);
try {
const result = await resolveProjectRegistration({
register: () => service.addProject({ profileId: host.profile.id, hostId: host.hostId }, name),
register: () => service.addProject({ profileId: host.profile.id, hostId: host.hostId }, customName),
confirm: (onConfirm) => (input.confirm ?? toast.confirm)({
onConfirm,
title: copy.archivedProjectTitle,
Expand Down Expand Up @@ -521,7 +522,7 @@ export function useTaskEntryController(
selectedProjectId: groupSelectedProjectId,
onSelectProject: (projectId: string) => selectProject(host, projectId),
...(host.capabilities.chooseClientDirectory || host.capabilities.chooseHostDirectory
? { onAdd: (name: string) => void addProjectForHost(host, name) }
? { onAdd: (name?: string) => void addProjectForHost(host, name) }
: {}),
...(host.capabilities.chooseClientDirectory
? { onRelink: (projectId: string) => void relinkProject(host, projectId) }
Expand Down Expand Up @@ -657,10 +658,11 @@ export function useTaskEntryController(
const addSelectedProject = useCallback(
(name?: string) => {
// The workspace-readiness notice hands this straight to an onClick, so a
// MouseEvent can arrive where a name is expected. Only a real string is a
// name; anything else means "no name given".
// MouseEvent can arrive where a name is expected. Only a non-empty string
// is a custom name; anything else means "use the folder name".
if (selectedHost) {
void addProjectForHost(selectedHost, typeof name === 'string' ? name : undefined);
const customName = typeof name === 'string' ? name.trim() : '';
void addProjectForHost(selectedHost, customName || undefined);
}
},
[addProjectForHost, selectedHost],
Expand All @@ -685,7 +687,7 @@ export function useTaskEntryController(
const openNewProject = useCallback(() => setNewProjectOpen(true), []);
const newProjectDialog = useMemo(() => newProjectOpen ? {
close: () => setNewProjectOpen(false),
submit: (name: string) => addSelectedProject(name),
submit: (name?: string) => addSelectedProject(name),
} : undefined, [newProjectOpen, addSelectedProject]);
const openFolder = useCallback(async (
folder: TaskEntryFolder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type { TaskEntryHostRef } from '../ports.js';
import { useTaskEntryHostModel } from './task-entry-provider.js';

export interface TaskEntryHostModel {
newProjectDialog?: { close(): void; submit(name: string): void };
newProjectDialog?: { close(): void; submit(name?: string): void };
directoryHost?: TaskEntryHostRef & { readonly name?: string };
directoryOpener?: HTMLElement | null;
closeDirectoryPicker(): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export function TaskEntryWorkspacePickerConsumer({
}),
onAdd:
activeSession.profileKind === 'local' && activeGroup.hostId
? (name: string) => void owner.commands.addSessionWorkspace({
? (name?: string) => void owner.commands.addSessionWorkspace({
sessionId: activeSession.id,
profileId: activeSession.profileId,
host: { profileId: activeGroup.id, hostId: activeGroup.hostId! },
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/src/renderer/settings/projects-settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,15 @@ export function ProjectsSettingsPage(props: {
}, [host, mountedRef, props.runtimeHostTargetVerified]);

/**
* Create a project from the name the New project dialog collected. The add
* call still owns the folder picker, so a cancelled picker simply leaves the
* list unchanged; the name rides along and the project is registered under it.
* Create a project, opening the folder picker. A typed name is applied in the
* same add; a blank name leaves the folder-derived catalog name alone.
*/
async function addNamedProject(name: string, registration: ProjectRegistration) {
async function addNamedProject(name: string | undefined, registration: ProjectRegistration) {
if (!host || !props.runtimeHostTargetVerified) return;
const trimmed = name?.trim();
await runRowAction('add', async () => {
const result = await registration.register(
() => window.maka.projects.add(host, { name }),
() => window.maka.projects.add(host, trimmed ? { name: trimmed } : undefined),
() => mountedRef.current,
);
// Cancelled or invalidated registrations must not trigger a refresh.
Expand Down
59 changes: 54 additions & 5 deletions packages/ui/src/__tests__/new-project-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
/**
* Submitting the New project dialog must be the dialog's business, never the
* browser's. The picker lives inside the composer's `<form>`, so a submit that
* escaped this component would navigate the window — the failure these cases
* pin down is `defaultPrevented`, not the name that travels.
* escaped this component would navigate the window. An empty name is a valid
* submit ("use the folder name"), not a reason to swallow the event.
*/

import assert from 'node:assert/strict';
Expand Down Expand Up @@ -82,7 +82,7 @@ async function mountDialog() {
installDomStubs(window);
Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true });

const submitted: string[] = [];
const submitted: Array<string | undefined> = [];
let openChanges = 0;
const container = document.querySelector('#root');
assert.ok(container);
Expand All @@ -103,8 +103,24 @@ async function mountDialog() {
);

return {
document,
submitted,
openChanges: () => openChanges,
type: async (value: string) => {
const input = document.querySelector('input');
assert.ok(input, 'no name field');
await act(() => {
input.value = value;
// linkedom does not run React's native input listener; call it the same
// way `project-add-restore` types into this field.
const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$'));
assert.ok(propsKey, 'no react props on the name field');
const props = (input as unknown as Record<string, unknown>)[propsKey] as {
onChange: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void;
};
props.onChange({ target: input, defaultPrevented: false });
});
},
submit: () => {
const form = document.querySelector('#maka-new-project-form');
assert.ok(form, 'no form');
Expand Down Expand Up @@ -135,16 +151,49 @@ test('a submit belongs to the dialog and never reaches the browser', async () =>
}
});

test('an empty name submits nothing at all', async () => {
test('an empty name still opens the folder picker', async () => {
const dialog = await mountDialog();
try {
const button = dialog.document.querySelector<HTMLButtonElement>(
'button[type="submit"][form="maka-new-project-form"]',
);
assert.ok(button, 'no choose-folder button');
assert.equal(button.disabled, false);

let event!: Event;
await act(() => {
event = dialog.submit();
});

assert.equal(event.defaultPrevented, true);
assert.deepEqual(dialog.submitted, []);
assert.equal(dialog.openChanges(), 1);
assert.deepEqual(dialog.submitted, [undefined]);
} finally {
await dialog.dispose();
}
});

test('a typed name is trimmed and passed through', async () => {
const dialog = await mountDialog();
try {
await dialog.type(' Lab notes ');
await act(() => {
dialog.submit();
});
assert.deepEqual(dialog.submitted, ['Lab notes']);
} finally {
await dialog.dispose();
}
});

test('a whitespace-only name is not a custom name', async () => {
const dialog = await mountDialog();
try {
await dialog.type(' ');
await act(() => {
dialog.submit();
});
assert.deepEqual(dialog.submitted, [undefined]);
} finally {
await dialog.dispose();
}
Expand Down
Loading