Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/feat-session-multi-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@heymp/scratchpad": patch
---

Delete multiple saved browser sessions at once.

Omitting session names on `session delete` now shows a checkbox picker so you can select several sessions before confirming. You can also pass multiple names on the command line:

```bash
npx @heymp/scratchpad@next session delete
npx @heymp/scratchpad@next session delete work personal
```
69 changes: 69 additions & 0 deletions src/sessionCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getSessionPath, getSessionsDir } from './utils.js';
import { cleanupTestSessionsDir, createTestSessionsDir, testSessionsEnv } from './testHelpers.js';

const testDir = dirname(fileURLToPath(import.meta.url));
let sessionsDir: string;

function runSessionDelete(args: string[], env: Record<string, string | undefined>) {
const script = `
import { sessionCommand } from './sessionCommand.js';
await sessionCommand.parseAsync(['node', 'session', 'delete', ...${JSON.stringify(args)}]);
`;
return spawnSync(process.execPath, ['--input-type=module', '-e', script], {
cwd: testDir,
encoding: 'utf8',
env,
});
}

describe('session delete', () => {
before(() => {
sessionsDir = createTestSessionsDir();
fs.mkdirSync(getSessionsDir(), { recursive: true });
});

after(() => {
cleanupTestSessionsDir(sessionsDir);
});

test('deletes a single session with --force', () => {
const sessionPath = getSessionPath('del-one');
fs.writeFileSync(sessionPath, '{}');

const result = runSessionDelete(['del-one', '--force'], testSessionsEnv(sessionsDir));
assert.strictEqual(result.status, 0);
assert.ok(result.stdout.includes('Deleted session "del-one"'));
assert.ok(!fs.existsSync(sessionPath));
});

test('deletes multiple sessions with --force', () => {
fs.writeFileSync(getSessionPath('del-a'), '{}');
fs.writeFileSync(getSessionPath('del-b'), '{}');

const result = runSessionDelete(['del-a', 'del-b', '--force'], testSessionsEnv(sessionsDir));
assert.strictEqual(result.status, 0);
assert.ok(result.stdout.includes('Deleted 2 sessions'));
assert.ok(!fs.existsSync(getSessionPath('del-a')));
assert.ok(!fs.existsSync(getSessionPath('del-b')));
});

test('duplicate names are deduplicated and do not cause errors', () => {
fs.writeFileSync(getSessionPath('dup-test'), '{}');

const result = runSessionDelete(['dup-test', 'dup-test', 'dup-test', '--force'], testSessionsEnv(sessionsDir));
assert.strictEqual(result.status, 0);
assert.ok(result.stdout.includes('Deleted session "dup-test"'));
assert.ok(!fs.existsSync(getSessionPath('dup-test')));
});

test('errors when session does not exist', () => {
const result = runSessionDelete(['nonexistent', '--force'], testSessionsEnv(sessionsDir));
assert.notStrictEqual(result.status, 0);
});
});
41 changes: 27 additions & 14 deletions src/sessionCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
listSessions,
OperationCancelledError,
pickSession,
pickSessions,
promptForSessionName,
parseBooleanOption,
validateSessionName,
Expand Down Expand Up @@ -66,33 +67,45 @@ const listSubcommand = new Command('list')
});

const deleteSubcommand = new Command('delete')
.description('Delete a saved browser session.')
.argument('[name]', 'session name to delete (shows a picker when omitted)')
.description('Delete saved browser sessions.')
.argument('[names...]', 'session names to delete (shows a checkbox picker when omitted)')
.option('--force', 'delete without confirmation')
.action(async (name, options) => {
.action(async (names, options) => {
try {
const sessionName = name ?? await pickSession();
const sessionNames = names.length > 0 ? [...new Set(names)] : await pickSessions();

const error = validateSessionName(sessionName);
if (error) {
throw new Error(error);
}
for (const sessionName of sessionNames) {
const error = validateSessionName(sessionName);
if (error) {
throw new Error(error);
}

const sessionPath = getSessionPath(sessionName);
if (!(await exists(sessionPath))) {
throw new Error(`Session "${sessionName}" not found.`);
const sessionPath = getSessionPath(sessionName);
if (!(await exists(sessionPath))) {
throw new Error(`Session "${sessionName}" not found.`);
}
}

if (!options.force) {
const confirmed = await confirmAction(`Delete session "${sessionName}"?`);
const message = sessionNames.length === 1
? `Delete session "${sessionNames[0]}"?`
: `Delete ${sessionNames.length} sessions (${sessionNames.join(', ')})?`;
const confirmed = await confirmAction(message);
if (!confirmed) {
console.log('Delete cancelled.');
return;
}
}

await unlink(sessionPath);
console.log(`Deleted session "${sessionName}".`);
for (const sessionName of sessionNames) {
await unlink(getSessionPath(sessionName));
}

if (sessionNames.length === 1) {
console.log(`Deleted session "${sessionNames[0]}".`);
} else {
console.log(`Deleted ${sessionNames.length} sessions: ${sessionNames.join(', ')}.`);
}
} catch (error) {
if (error instanceof OperationCancelledError) {
console.log('Delete cancelled.');
Expand Down
67 changes: 66 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from 'node:path';
import os from 'node:os';
import * as readline from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { cancel, confirm, isCancel, select, text } from '@clack/prompts';
import { cancel, confirm, isCancel, multiselect, select, text } from '@clack/prompts';

const SESSION_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const TRUE_BOOLEAN_VALUES = new Set(['true', '1', 'yes']);
Expand Down Expand Up @@ -246,6 +246,71 @@ export async function pickSession() {
return pickSessionWithReadline(sessions);
}

async function pickSessionsWithReadline(sessions: SessionInfo[]) {
console.log('\nAvailable sessions:');
sessions.forEach((session, index) => {
const saved = formatSessionSavedDate(session.savedAt);
console.log(` ${index + 1}. ${session.name} (saved ${saved})`);
});
console.log('');

const rl = readline.createInterface({ input: stdin, output: stdout });

try {
while (true) {
const answer = await rl.question(`Select sessions to delete (comma-separated numbers, e.g. 1,3): `);
const selections = answer
.split(',')
.map((part) => Number.parseInt(part.trim(), 10))
.filter((selection) => !Number.isNaN(selection));

if (selections.length === 0) {
console.error('Please enter at least one session number.');
continue;
}

const invalid = selections.filter((selection) => selection < 1 || selection > sessions.length);
if (invalid.length > 0) {
console.error(`Please enter numbers between 1 and ${sessions.length}.`);
continue;
}

const uniqueSelections = [...new Set(selections)];
return uniqueSelections.map((selection) => sessions[selection - 1].name);
}
} finally {
rl.close();
}
}

async function pickSessionsWithClack(sessions: SessionInfo[]) {
const choices = await multiselect({
message: 'Select sessions to delete',
options: formatSessionOptions(sessions),
required: true,
});

if (isCancel(choices)) {
cancel('Session selection cancelled.');
throw new OperationCancelledError();
}

return choices;
}

export async function pickSessions() {
const sessions = await listSessions();
if (sessions.length === 0) {
throw new Error('No saved sessions found. Run `scratchpad session login` to create one.');
}

if (stdin.isTTY) {
return pickSessionsWithClack(sessions);
}

return pickSessionsWithReadline(sessions);
}

/**
* Template Literal function that converts an string
* containing ESM javascript to data URI.
Expand Down
Loading