From 358cd3ea8990537229a87a156506e61bbcadcfee Mon Sep 17 00:00:00 2001 From: SecPan Date: Fri, 10 Jul 2026 03:34:10 -0700 Subject: [PATCH] Extract home document actions from App.vue Moves the home selection-mode orchestration (pin, delete, share, rename) into a createHomeDocumentActions factory in features/home. The factory receives the App-owned stores (drafts, recents, pins), the selection instance, the open editor session state, and the native adapters, and reuses the existing share/rename workflows and pinned-document helpers unchanged. Rendering stays in the home components; the template bindings are untouched because App destructures the same four handler names. The only inlining is the mount-time pin prune, which keeps its own two-line stored-id collection now that the actions module owns the equivalent helper. Behavior, storage formats, notices, and log lines are unchanged; the moved logic is covered by new unit tests over the real share/rename workflows plus the existing home-document-management E2E suite. --- src/App.vue | 227 +++----------- src/features/home/homeDocumentActions.test.ts | 288 ++++++++++++++++++ src/features/home/homeDocumentActions.ts | 277 +++++++++++++++++ 3 files changed, 602 insertions(+), 190 deletions(-) create mode 100644 src/features/home/homeDocumentActions.test.ts create mode 100644 src/features/home/homeDocumentActions.ts diff --git a/src/App.vue b/src/App.vue index 7c2a286..f793845 100644 --- a/src/App.vue +++ b/src/App.vue @@ -115,7 +115,6 @@ import { } from './features/editor/editorRuntime' import { removeLocalDraft, - renameLocalDraft, upsertLocalDraft, type LocalDraftRecord, } from './lib/localDrafts' @@ -133,7 +132,6 @@ import { areAllDocumentsPinned, getPinnedDocumentIds, prunePinnedDocuments, - togglePinnedDocuments, type PinnedDocumentRecord, } from './lib/pinnedDocuments' import { @@ -154,8 +152,7 @@ import { type HomeDocumentText, } from './features/home/homeDocuments' import { useDocumentSelection } from './features/home/useDocumentSelection' -import { renameAndroidRecentDocumentWorkflow } from './features/android-documents/renameRecentDocumentWorkflow' -import { shareRecentDocumentsWorkflow } from './features/android-documents/shareRecentDocumentsWorkflow' +import { createHomeDocumentActions } from './features/home/homeDocumentActions' import { DEFAULT_SETTINGS_PAGE, SETTINGS_PAGES, @@ -1794,191 +1791,35 @@ async function openFileFromAndroid() { } } -function getSelectedDocumentRecords() { - const selectedIds = homeSelection.selectedIds.value - return documentItems.value.filter(record => selectedIds.has(record.id)) -} - -// Pins may only be pruned against everything in storage: the home list hides -// recovery drafts and setting-disabled drafts without deleting them. -function getAllStoredDocumentIds() { - return [ - ...localDrafts.value.map(draft => draft.id), - ...androidRecentDocuments.value.map(record => record.id), - ] -} - -function pinSelectedDocuments() { - const selectedIds = [...homeSelection.selectedIds.value] - if (selectedIds.length === 0) { - return - } - - persistPinnedDocuments( - togglePinnedDocuments(pinnedDocuments.value, selectedIds, new Date().toISOString()), - ) - appLog.info('toggled home document pins', { count: selectedIds.length }) - homeSelection.clear() -} - -function deleteSelectedDocuments() { - const selectedRecords = getSelectedDocumentRecords() - if (selectedRecords.length === 0) { - return - } - - const draftIds = new Set( - selectedRecords.filter(record => record.kind === 'local-draft').map(record => record.id), - ) - const androidRecords = selectedRecords.filter(record => record.kind === 'android-document') - - if (draftIds.size > 0) { - persistLocalDrafts(localDrafts.value.filter(draft => !draftIds.has(draft.id))) - } - - if (androidRecords.length > 0) { - const androidIds = new Set(androidRecords.map(record => record.id)) - persistAndroidRecentDocuments( - androidRecentDocuments.value.filter(record => !androidIds.has(record.id)), - ) - } - - persistPinnedDocuments(prunePinnedDocuments(pinnedDocuments.value, getAllStoredDocumentIds())) - - // A deleted document must not resurrect from the open editor session. - if (selectedRecords.some(record => record.id === documentState.value.id)) { - documentState.value = createUntitledDocument({ autosaveTarget: 'local-draft' }) - currentAndroidDocumentCanWrite.value = false - } - - appLog.info('deleted home documents', { - drafts: draftIds.size, - androidDocuments: androidRecords.length, - }) - homeSelection.clear() -} - -async function shareSelectedDocuments() { - const selectedRecords = getSelectedDocumentRecords() - if (selectedRecords.length === 0) { - return - } - - const result = await shareRecentDocumentsWorkflow({ - documents: selectedRecords, - readAndroidMarkdownDocument: sourceUri => - readAndroidMarkdownDocument(sourceUri, androidMarkdownSettings.value), - shareAndroidMarkdownDocument, - shareAndroidMarkdownDocuments, - getAndroidDocumentUserMessage, - imageSharingSettings: imageSharingSettings.value, - markdownSaveSettings: markdownSaveSettings.value, - logger: androidDocumentLog, - }) - - if (result.kind === 'failed') { - // Keep the selection so a transient failure can be retried as-is. - homeNotice.value = result.status - return - } - - homeNotice.value = null - homeSelection.clear() -} - -async function renameSelectedDocument(id: string, name: string) { - const record = documentItems.value.find(item => item.id === id) - if (!record) { - appLog.warn('rename target not found', { id }) - return - } - - if (record.kind === 'local-draft') { - persistLocalDrafts(renameLocalDraft(localDrafts.value, id, name)) - if (documentState.value.id === id) { - // Keeps the new name flowing through the next autosave of an open session. - documentState.value = { ...documentState.value, displayName: name.trim() } - } - appLog.info('renamed local draft', { id }) - homeNotice.value = null - homeSelection.clear() - return - } - - const result = await renameAndroidRecentDocumentWorkflow({ - record, - newName: name, - readAndroidMarkdownDocument: sourceUri => - readAndroidMarkdownDocument(sourceUri, androidMarkdownSettings.value), - renameAndroidMarkdownDocument, - getAndroidDocumentUserMessage, - logger: androidDocumentLog, - }) - - if (result.kind === 'failed') { - homeNotice.value = result.status - return - } - - const remainingRecentDocuments = androidRecentDocuments.value.filter( - item => item.id !== result.previousId, - ) - persistAndroidRecentDocuments( - result.accessRetained - ? [result.updatedRecord, ...remainingRecentDocuments] - : remainingRecentDocuments, - ) - - // Renaming can change the SAF URI; migrate everything keyed by it. - if (result.updatedRecord.id !== result.previousId) { - if (result.accessRetained && pinnedDocumentIds.value.has(result.previousId)) { - persistPinnedDocuments( - pinnedDocuments.value.map(pin => - pin.id === result.previousId ? { ...pin, id: result.updatedRecord.id } : pin, - ), - ) - } - - const previousUri = record.sourceUri - const nextUri = result.updatedRecord.sourceUri - if (previousUri && nextUri && previousUri !== nextUri) { - const previousRecoveryId = getAndroidRecoveryDraftId(previousUri) - const recoveryDraft = localDrafts.value.find(draft => draft.id === previousRecoveryId) - if (recoveryDraft) { - persistLocalDrafts([ - { ...recoveryDraft, id: getAndroidRecoveryDraftId(nextUri) }, - ...removeLocalDraft(localDrafts.value, previousRecoveryId), - ]) - } - } - } - - if (!result.accessRetained) { - // A session grant cannot back a durable home entry or pin. Recovery - // content remains local under the migrated URI until the user reopens the - // renamed file through Android and obtains a new persisted grant. - persistPinnedDocuments( - pinnedDocuments.value.filter( - pin => pin.id !== result.previousId && pin.id !== result.updatedRecord.id, - ), - ) - } - - if (documentState.value.id === result.previousId) { - documentState.value = { - ...documentState.value, - id: result.updatedRecord.id, - sourceUri: result.updatedRecord.sourceUri, - displayName: result.updatedRecord.displayName, - } - } - - // The rename itself succeeded, but a session-scoped URI must not survive in - // durable Recents. Tell the user how to restore the entry explicitly. - homeNotice.value = result.accessRetained ? null : RENAME_TEMPORARY_ACCESS_MESSAGE - homeSelection.clear() -} - +const { + pinSelectedDocuments, + deleteSelectedDocuments, + shareSelectedDocuments, + renameSelectedDocument, +} = createHomeDocumentActions({ + documentItems, + selection: homeSelection, + homeNotice, + localDrafts, + persistLocalDrafts, + androidRecentDocuments, + persistAndroidRecentDocuments, + pinnedDocuments, + persistPinnedDocuments, + documentState, + currentAndroidDocumentCanWrite, + readAndroidMarkdownDocument: sourceUri => + readAndroidMarkdownDocument(sourceUri, androidMarkdownSettings.value), + shareAndroidMarkdownDocument, + shareAndroidMarkdownDocuments, + renameAndroidMarkdownDocument, + getAndroidDocumentUserMessage, + imageSharingSettings, + markdownSaveSettings, + renameTemporaryAccessMessage: RENAME_TEMPORARY_ACCESS_MESSAGE, + appLogger: appLog, + documentLogger: androidDocumentLog, +}) async function openDocument(id: string) { const recentDocument = documentItems.value.find(record => record.id === id) if (!recentDocument) { @@ -2409,7 +2250,13 @@ onMounted(() => { documentState.value = createUntitledDocument({ autosaveTarget: 'local-draft' }) } - const prunedPins = prunePinnedDocuments(pinnedDocuments.value, getAllStoredDocumentIds()) + // Prune against everything in storage, not the filtered home list — hidden + // recovery drafts and setting-disabled drafts still exist and keep pins. + const storedDocumentIds = [ + ...localDrafts.value.map(draft => draft.id), + ...androidRecentDocuments.value.map(record => record.id), + ] + const prunedPins = prunePinnedDocuments(pinnedDocuments.value, storedDocumentIds) if (prunedPins.length !== pinnedDocuments.value.length) { persistPinnedDocuments(prunedPins) } diff --git a/src/features/home/homeDocumentActions.test.ts b/src/features/home/homeDocumentActions.test.ts new file mode 100644 index 0000000..dc4b217 --- /dev/null +++ b/src/features/home/homeDocumentActions.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createHomeDocumentActions } from './homeDocumentActions' +import { useDocumentSelection } from './useDocumentSelection' +import { getAndroidDocumentUserMessage, AndroidDocumentError } from '../../lib/androidDocuments' +import { createUntitledDocument } from '../../lib/documentState' +import { getRecentDocumentListItems } from '../../lib/recentDocuments' +import type { LocalDraftRecord } from '../../lib/localDrafts' +import type { PinnedDocumentRecord } from '../../lib/pinnedDocuments' +import type { RecentDocumentRecord } from '../../lib/recentDocuments' +import type { ImageSharingSettings } from '../android-documents/imageSharingSettings' +import { DEFAULT_MARKDOWN_SAVE_SETTINGS } from '../settings/advancedSettings' + +const noopLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } + +const draftRecord: LocalDraftRecord = { + id: 'draft-1', + markdown: '# Draft one\n\nbody', + createdAt: '2026-07-09T00:00:00.000Z', + updatedAt: '2026-07-09T00:01:00.000Z', + lastSavedAt: '2026-07-09T00:01:00.000Z', +} + +const androidRecord: RecentDocumentRecord = { + id: 'android-document:content://test/notes.md', + kind: 'android-document', + displayName: 'notes.md', + title: 'notes', + sourceUri: 'content://test/notes.md', + providerName: 'Documents', + pathHint: 'notes.md', + markdownPreview: null, + createdAt: '2026-07-09T00:00:00.000Z', + updatedAt: '2026-07-09T00:00:00.000Z', + lastOpenedAt: '2026-07-09T00:00:00.000Z', + lastSavedAt: null, + autosaveState: 'clean', + canWrite: true, +} + +const draftListRecord: RecentDocumentRecord = { + ...androidRecord, + id: 'draft-1', + kind: 'local-draft', + displayName: 'Draft one', + title: 'Draft one', + sourceUri: null, + providerName: 'Local draft', + pathHint: null, + markdownPreview: draftRecord.markdown, +} + +function createActions(overrides: { + drafts?: LocalDraftRecord[] + androidDocuments?: RecentDocumentRecord[] + pins?: PinnedDocumentRecord[] + selectedIds?: string[] + documentState?: ReturnType +} = {}) { + const localDrafts = ref(overrides.drafts ?? [draftRecord]) + const androidRecentDocuments = ref(overrides.androidDocuments ?? [androidRecord]) + const pinnedDocuments = ref(overrides.pins ?? []) + const documentItems = ref( + getRecentDocumentListItems([draftListRecord, ...androidRecentDocuments.value]), + ) + const selection = useDocumentSelection() + for (const id of overrides.selectedIds ?? []) { + selection.toggle(id) + } + + const options = { + documentItems, + selection, + homeNotice: ref('stale notice'), + localDrafts, + persistLocalDrafts: vi.fn((drafts: LocalDraftRecord[]) => { + localDrafts.value = drafts + }), + androidRecentDocuments, + persistAndroidRecentDocuments: vi.fn((records: RecentDocumentRecord[]) => { + androidRecentDocuments.value = records + }), + pinnedDocuments, + persistPinnedDocuments: vi.fn((records: PinnedDocumentRecord[]) => { + pinnedDocuments.value = records + }), + documentState: ref( + overrides.documentState ?? createUntitledDocument({ autosaveTarget: 'local-draft' }), + ), + currentAndroidDocumentCanWrite: ref(true), + readAndroidMarkdownDocument: vi.fn().mockResolvedValue({ markdown: '# notes' }), + shareAndroidMarkdownDocument: vi.fn().mockResolvedValue({ + displayName: 'x.md', + mimeType: 'text/markdown', + bytes: 1, + imageCount: 0, + sharedFileCount: 1, + }), + shareAndroidMarkdownDocuments: vi.fn().mockResolvedValue({ + displayName: 'x.md', + mimeType: 'text/markdown', + bytes: 2, + imageCount: 0, + sharedFileCount: 2, + }), + renameAndroidMarkdownDocument: vi.fn().mockResolvedValue({ + sourceUri: 'content://test/renamed.md', + displayName: 'renamed.md', + providerName: 'Documents', + pathHint: 'renamed.md', + canWrite: true, + persisted: true, + }), + getAndroidDocumentUserMessage, + imageSharingSettings: ref({ + shareImages: 'attach', + imageCopyImages: true, + } as ImageSharingSettings), + markdownSaveSettings: ref(DEFAULT_MARKDOWN_SAVE_SETTINGS), + renameTemporaryAccessMessage: 'temporary access notice', + appLogger: noopLogger, + documentLogger: noopLogger, + } + + return { actions: createHomeDocumentActions(options), options, selection } +} + +describe('homeDocumentActions', () => { + it('toggles pins for the selection and exits selection without touching notices', () => { + const { actions, options, selection } = createActions({ selectedIds: ['draft-1'] }) + + actions.pinSelectedDocuments() + + expect(options.pinnedDocuments.value.map(pin => pin.id)).toEqual(['draft-1']) + // Pinning is unrelated to whatever the home notice is currently showing. + expect(options.homeNotice.value).toBe('stale notice') + expect(selection.isActive.value).toBe(false) + }) + + it('does nothing when pinning with an empty selection', () => { + const { actions, options } = createActions() + + actions.pinSelectedDocuments() + + expect(options.persistPinnedDocuments).not.toHaveBeenCalled() + }) + + it('deletes drafts permanently, removes device files from the list, and prunes pins', () => { + const { actions, options, selection } = createActions({ + selectedIds: ['draft-1', androidRecord.id], + pins: [ + { id: 'draft-1', pinnedAt: '2026-07-09T00:00:00.000Z' }, + { id: androidRecord.id, pinnedAt: '2026-07-09T00:00:00.000Z' }, + ], + }) + + actions.deleteSelectedDocuments() + + expect(options.localDrafts.value).toEqual([]) + expect(options.androidRecentDocuments.value).toEqual([]) + expect(options.pinnedDocuments.value).toEqual([]) + // Deleting must not dismiss an unrelated notice the user may be reading. + expect(options.homeNotice.value).toBe('stale notice') + expect(selection.isActive.value).toBe(false) + }) + + it('resets the open editor session when its document is deleted', () => { + const openDraftState = { + ...createUntitledDocument({ markdown: '# Draft one', autosaveTarget: 'local-draft' }), + id: 'draft-1', + } + const { actions, options } = createActions({ + selectedIds: ['draft-1'], + documentState: openDraftState, + }) + + actions.deleteSelectedDocuments() + + expect(options.documentState.value.id).not.toBe('draft-1') + expect(options.documentState.value.markdown).toBe('') + expect(options.currentAndroidDocumentCanWrite.value).toBe(false) + }) + + it('keeps the selection and surfaces the message when sharing fails', async () => { + const { actions, options, selection } = createActions({ selectedIds: [androidRecord.id] }) + options.readAndroidMarkdownDocument.mockRejectedValue( + new AndroidDocumentError('DOCUMENT_NOT_FOUND', 'gone'), + ) + + await actions.shareSelectedDocuments() + + expect(options.homeNotice.value).toBe( + 'This file was moved or deleted. Open it again from Android.', + ) + expect(selection.isActive.value).toBe(true) + }) + + it('clears the selection and notice after a successful share', async () => { + const { actions, options, selection } = createActions({ + selectedIds: ['draft-1', androidRecord.id], + }) + + await actions.shareSelectedDocuments() + + expect(options.shareAndroidMarkdownDocuments).toHaveBeenCalledTimes(1) + expect(options.homeNotice.value).toBeNull() + expect(selection.isActive.value).toBe(false) + }) + + it('renames a draft and patches an open editor session for that draft', async () => { + const openDraftState = { + ...createUntitledDocument({ markdown: '# Draft one', autosaveTarget: 'local-draft' }), + id: 'draft-1', + } + const { actions, options, selection } = createActions({ + selectedIds: ['draft-1'], + documentState: openDraftState, + }) + + await actions.renameSelectedDocument('draft-1', ' Trip plan ') + + expect(options.localDrafts.value[0].displayName).toBe('Trip plan') + expect(options.documentState.value.displayName).toBe('Trip plan') + expect(options.homeNotice.value).toBeNull() + expect(selection.isActive.value).toBe(false) + }) + + it('migrates the record, pin, and recovery draft when a rename changes the URI', async () => { + const recoveryDraft: LocalDraftRecord = { + ...draftRecord, + id: 'android-recovery:content://test/notes.md', + markdown: '# Unsaved edits', + } + const { actions, options } = createActions({ + selectedIds: [androidRecord.id], + drafts: [draftRecord, recoveryDraft], + pins: [{ id: androidRecord.id, pinnedAt: '2026-07-09T00:00:00.000Z' }], + }) + + await actions.renameSelectedDocument(androidRecord.id, 'renamed') + + expect(options.androidRecentDocuments.value[0]).toMatchObject({ + id: 'android-document:content://test/renamed.md', + sourceUri: 'content://test/renamed.md', + displayName: 'renamed.md', + }) + expect(options.pinnedDocuments.value.map(pin => pin.id)).toEqual([ + 'android-document:content://test/renamed.md', + ]) + expect( + options.localDrafts.value.some( + draft => draft.id === 'android-recovery:content://test/renamed.md', + ), + ).toBe(true) + expect(options.homeNotice.value).toBeNull() + }) + + it('drops the entry and its pins when a rename keeps only temporary access', async () => { + const { actions, options } = createActions({ + selectedIds: [androidRecord.id], + pins: [{ id: androidRecord.id, pinnedAt: '2026-07-09T00:00:00.000Z' }], + }) + options.renameAndroidMarkdownDocument.mockResolvedValue({ + sourceUri: 'content://test/renamed.md', + displayName: 'renamed.md', + providerName: 'Documents', + pathHint: 'renamed.md', + canWrite: true, + persisted: false, + }) + + await actions.renameSelectedDocument(androidRecord.id, 'renamed') + + expect(options.androidRecentDocuments.value).toEqual([]) + expect(options.pinnedDocuments.value).toEqual([]) + expect(options.homeNotice.value).toBe('temporary access notice') + }) + + it('only warns when the rename target no longer exists', async () => { + const { actions, options } = createActions() + + await actions.renameSelectedDocument('missing-id', 'name') + + expect(options.persistLocalDrafts).not.toHaveBeenCalled() + expect(options.renameAndroidMarkdownDocument).not.toHaveBeenCalled() + expect(noopLogger.warn).toHaveBeenCalledWith('rename target not found', { id: 'missing-id' }) + }) +}) diff --git a/src/features/home/homeDocumentActions.ts b/src/features/home/homeDocumentActions.ts new file mode 100644 index 0000000..04f3a23 --- /dev/null +++ b/src/features/home/homeDocumentActions.ts @@ -0,0 +1,277 @@ +import type { Ref } from 'vue' +import type { DocumentSelection } from './useDocumentSelection' +import { getAndroidRecoveryDraftId } from '../android-documents/androidRecoveryDrafts' +import { renameAndroidRecentDocumentWorkflow } from '../android-documents/renameRecentDocumentWorkflow' +import { shareRecentDocumentsWorkflow } from '../android-documents/shareRecentDocumentsWorkflow' +import type { ImageSharingSettings } from '../android-documents/imageSharingSettings' +import type { MarkdownSaveSettings } from '../settings/advancedSettings' +import type { + AndroidShareDocumentPayload, + AndroidShareResult, + RenamedAndroidDocument, +} from '../../lib/androidDocuments' +import { createUntitledDocument, type MarkdownDocumentState } from '../../lib/documentState' +import { removeLocalDraft, renameLocalDraft, type LocalDraftRecord } from '../../lib/localDrafts' +import { + prunePinnedDocuments, + togglePinnedDocuments, + type PinnedDocumentRecord, +} from '../../lib/pinnedDocuments' +import type { RecentDocumentListItem, RecentDocumentRecord } from '../../lib/recentDocuments' + +interface ActionsLogger { + info(message: string, context?: unknown): void + warn(message: string, context?: unknown): void + error(message: string, context?: unknown): void +} + +export interface HomeDocumentActionsOptions { + // Home list + selection state owned by App. + documentItems: Ref + selection: DocumentSelection + homeNotice: Ref + // Document stores and their persistence, owned by App. + localDrafts: Ref + persistLocalDrafts: (drafts: LocalDraftRecord[]) => void + androidRecentDocuments: Ref + persistAndroidRecentDocuments: (records: RecentDocumentRecord[]) => void + pinnedDocuments: Ref + persistPinnedDocuments: (records: PinnedDocumentRecord[]) => void + // Open editor session state: deletes and renames must not desync it. + documentState: Ref + currentAndroidDocumentCanWrite: Ref + // Native adapters (settings-wrapped where the platform call needs them). + readAndroidMarkdownDocument: (sourceUri: string) => Promise<{ markdown: string }> + shareAndroidMarkdownDocument: ( + markdown: string, + suggestedName: string, + options: { attachImages: boolean, encoding: MarkdownSaveSettings['encoding'] }, + ) => Promise + shareAndroidMarkdownDocuments: ( + documents: AndroidShareDocumentPayload[], + options: { encoding: MarkdownSaveSettings['encoding'] }, + ) => Promise + renameAndroidMarkdownDocument: ( + sourceUri: string, + newName: string, + ) => Promise + getAndroidDocumentUserMessage: (error: unknown) => string + imageSharingSettings: Ref + markdownSaveSettings: Ref + renameTemporaryAccessMessage: string + appLogger: ActionsLogger + documentLogger: ActionsLogger +} + +export interface HomeDocumentActions { + pinSelectedDocuments(): void + deleteSelectedDocuments(): void + shareSelectedDocuments(): Promise + renameSelectedDocument(id: string, name: string): Promise +} + +/** + * Orchestrates the home selection-mode actions (pin, delete, share, rename) + * over the App-owned stores. Rendering stays in the home components; storage + * shapes and the share/rename workflows are reused unchanged. + */ +export function createHomeDocumentActions(options: HomeDocumentActionsOptions): HomeDocumentActions { + function getSelectedDocumentRecords() { + const selectedIds = options.selection.selectedIds.value + return options.documentItems.value.filter(record => selectedIds.has(record.id)) + } + + // Pins may only be pruned against everything in storage: the home list hides + // recovery drafts and setting-disabled drafts without deleting them. + function getAllStoredDocumentIds() { + return [ + ...options.localDrafts.value.map(draft => draft.id), + ...options.androidRecentDocuments.value.map(record => record.id), + ] + } + + function pinSelectedDocuments() { + const selectedIds = [...options.selection.selectedIds.value] + if (selectedIds.length === 0) { + return + } + + options.persistPinnedDocuments( + togglePinnedDocuments(options.pinnedDocuments.value, selectedIds, new Date().toISOString()), + ) + options.appLogger.info('toggled home document pins', { count: selectedIds.length }) + options.selection.clear() + } + + function deleteSelectedDocuments() { + const selectedRecords = getSelectedDocumentRecords() + if (selectedRecords.length === 0) { + return + } + + const draftIds = new Set( + selectedRecords.filter(record => record.kind === 'local-draft').map(record => record.id), + ) + const androidRecords = selectedRecords.filter(record => record.kind === 'android-document') + + if (draftIds.size > 0) { + options.persistLocalDrafts(options.localDrafts.value.filter(draft => !draftIds.has(draft.id))) + } + + if (androidRecords.length > 0) { + const androidIds = new Set(androidRecords.map(record => record.id)) + options.persistAndroidRecentDocuments( + options.androidRecentDocuments.value.filter(record => !androidIds.has(record.id)), + ) + } + + options.persistPinnedDocuments( + prunePinnedDocuments(options.pinnedDocuments.value, getAllStoredDocumentIds()), + ) + + // A deleted document must not resurrect from the open editor session. + if (selectedRecords.some(record => record.id === options.documentState.value.id)) { + options.documentState.value = createUntitledDocument({ autosaveTarget: 'local-draft' }) + options.currentAndroidDocumentCanWrite.value = false + } + + options.appLogger.info('deleted home documents', { + drafts: draftIds.size, + androidDocuments: androidRecords.length, + }) + options.selection.clear() + } + + async function shareSelectedDocuments() { + const selectedRecords = getSelectedDocumentRecords() + if (selectedRecords.length === 0) { + return + } + + const result = await shareRecentDocumentsWorkflow({ + documents: selectedRecords, + readAndroidMarkdownDocument: options.readAndroidMarkdownDocument, + shareAndroidMarkdownDocument: options.shareAndroidMarkdownDocument, + shareAndroidMarkdownDocuments: options.shareAndroidMarkdownDocuments, + getAndroidDocumentUserMessage: options.getAndroidDocumentUserMessage, + imageSharingSettings: options.imageSharingSettings.value, + markdownSaveSettings: options.markdownSaveSettings.value, + logger: options.documentLogger, + }) + + if (result.kind === 'failed') { + // Keep the selection so a transient failure can be retried as-is. + options.homeNotice.value = result.status + return + } + + options.homeNotice.value = null + options.selection.clear() + } + + async function renameSelectedDocument(id: string, name: string) { + const record = options.documentItems.value.find(item => item.id === id) + if (!record) { + options.appLogger.warn('rename target not found', { id }) + return + } + + if (record.kind === 'local-draft') { + options.persistLocalDrafts(renameLocalDraft(options.localDrafts.value, id, name)) + if (options.documentState.value.id === id) { + // Keeps the new name flowing through the next autosave of an open session. + options.documentState.value = { ...options.documentState.value, displayName: name.trim() } + } + options.appLogger.info('renamed local draft', { id }) + options.homeNotice.value = null + options.selection.clear() + return + } + + const result = await renameAndroidRecentDocumentWorkflow({ + record, + newName: name, + readAndroidMarkdownDocument: options.readAndroidMarkdownDocument, + renameAndroidMarkdownDocument: options.renameAndroidMarkdownDocument, + getAndroidDocumentUserMessage: options.getAndroidDocumentUserMessage, + logger: options.documentLogger, + }) + + if (result.kind === 'failed') { + options.homeNotice.value = result.status + return + } + + const remainingRecentDocuments = options.androidRecentDocuments.value.filter( + item => item.id !== result.previousId, + ) + options.persistAndroidRecentDocuments( + result.accessRetained + ? [result.updatedRecord, ...remainingRecentDocuments] + : remainingRecentDocuments, + ) + + // Renaming can change the SAF URI; migrate everything keyed by it. + if (result.updatedRecord.id !== result.previousId) { + if ( + result.accessRetained + && options.pinnedDocuments.value.some(pin => pin.id === result.previousId) + ) { + options.persistPinnedDocuments( + options.pinnedDocuments.value.map(pin => + pin.id === result.previousId ? { ...pin, id: result.updatedRecord.id } : pin, + ), + ) + } + + const previousUri = record.sourceUri + const nextUri = result.updatedRecord.sourceUri + if (previousUri && nextUri && previousUri !== nextUri) { + const previousRecoveryId = getAndroidRecoveryDraftId(previousUri) + const recoveryDraft = options.localDrafts.value.find( + draft => draft.id === previousRecoveryId, + ) + if (recoveryDraft) { + options.persistLocalDrafts([ + { ...recoveryDraft, id: getAndroidRecoveryDraftId(nextUri) }, + ...removeLocalDraft(options.localDrafts.value, previousRecoveryId), + ]) + } + } + } + + if (!result.accessRetained) { + // A session grant cannot back a durable home entry or pin. Recovery + // content remains local under the migrated URI until the user reopens the + // renamed file through Android and obtains a new persisted grant. + options.persistPinnedDocuments( + options.pinnedDocuments.value.filter( + pin => pin.id !== result.previousId && pin.id !== result.updatedRecord.id, + ), + ) + } + + if (options.documentState.value.id === result.previousId) { + options.documentState.value = { + ...options.documentState.value, + id: result.updatedRecord.id, + sourceUri: result.updatedRecord.sourceUri, + displayName: result.updatedRecord.displayName, + } + } + + // The rename itself succeeded, but a session-scoped URI must not survive in + // durable Recents. Tell the user how to restore the entry explicitly. + options.homeNotice.value = result.accessRetained + ? null + : options.renameTemporaryAccessMessage + options.selection.clear() + } + + return { + pinSelectedDocuments, + deleteSelectedDocuments, + shareSelectedDocuments, + renameSelectedDocument, + } +}