diff --git a/.changeset/feat-session-multi-delete.md b/.changeset/feat-session-multi-delete.md new file mode 100644 index 0000000..9cdfeac --- /dev/null +++ b/.changeset/feat-session-multi-delete.md @@ -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 +``` diff --git a/src/sessionCommand.test.ts b/src/sessionCommand.test.ts new file mode 100644 index 0000000..7260947 --- /dev/null +++ b/src/sessionCommand.test.ts @@ -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) { + 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); + }); +}); diff --git a/src/sessionCommand.ts b/src/sessionCommand.ts index d9cac42..945660f 100644 --- a/src/sessionCommand.ts +++ b/src/sessionCommand.ts @@ -11,6 +11,7 @@ import { listSessions, OperationCancelledError, pickSession, + pickSessions, promptForSessionName, parseBooleanOption, validateSessionName, @@ -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.'); diff --git a/src/utils.ts b/src/utils.ts index 9fc3887..fcd8a48 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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']); @@ -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.