import { createTwoFilesPatch } from 'diff'; const $ = selector => document.querySelector(selector); const text = value => { if (value === null) return ''; const decoded = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(atob(value), c => c.charCodeAt(0))); if (decoded.includes('\0')) throw Error('Binary file'); return decoded; }; export function createLocalFolderUI({ rpc, notice, canSwitch, onSwitch }) { let handle, saved, current, busy = false, opening = false, reviewPatches = []; const dialog = $('#local-review'); const embedded = window.self !== window.top; const supported = typeof window.showDirectoryPicker === 'function'; function controls() { $('#open-folder').hidden = Boolean(current); $('#reconnect-folder').hidden = Boolean(current) || !saved; $('#local-folder-bar').hidden = !current; for (const id of ['open-folder', 'reconnect-folder', 'review-changes', 'refresh-folder', 'disconnect-folder']) $('#' + id).disabled = busy || opening; $('#review-changes').disabled ||= !current?.count; $('#refresh-folder').disabled ||= Boolean(current?.count); $('#review-apply').disabled = busy || !reviewPatches.length; $('#review-discard').disabled = busy || !reviewPatches.length; $('#review-close').disabled = busy; if (current) { $('#local-folder-name').textContent = current.name; $('#local-folder-hint').textContent = current.count ? `${current.count} staged change${current.count === 1 ? '' : 's'} · original files unchanged until Apply` : 'Local folder · edits are staged until you apply them'; $('#review-changes').textContent = current.count ? `Review & apply (${current.count})` : 'Review & apply'; } $('#export').textContent = current ? 'Export changes' : 'Export'; $('#save').textContent = current ? 'Stage edit' : 'Save'; } function available() { if (embedded) { $('#open-tab').href = new URL('?local-folder', location.href).href; $('#open-tab').hidden = false; notice('Open the workspace in a new tab, then choose Link a folder. Browsers cannot grant folder access inside this embedded Space.'); return false; } if (!supported) { notice('Direct local-folder access needs a supported browser, such as Chrome or Edge. In this browser, use Import and Export to work with copies of your files.'); return false; } return true; } async function connect(restore) { if (!available() || busy || opening) return; try { canSwitch(); opening = true; controls(); // Invoke the picker/permission request directly from the click, before // waiting for the worker: these APIs require transient user activation. let next; if (restore) { next = saved.handle; if (await next.requestPermission({ mode: 'read' }) !== 'granted') throw Error('Folder access was not granted.'); } else { if (saved?.count && !window.confirm('Opening another folder replaces the saved staged edits for the previous folder. Reconnect to review them first, or continue?')) return; next = await window.showDirectoryPicker({ id: 'pi-workspace', mode: 'read' }); } const state = await rpc('local_open', { handle: next, restore }); handle = next; saved = { handle: next, count: state.localFolder.count }; onSwitch(state); notice(`Opened ${next.name}. Review & apply writes staged changes to disk. Files load on demand; Refresh picks up external edits.`); } catch (error) { if (error.name !== 'AbortError') notice(error.message); } finally { opening = false; controls(); } } $('#open-folder').onclick = () => connect(false); $('#reconnect-folder').onclick = () => connect(true); $('#disconnect-folder').onclick = async () => { try { canSwitch(); saved = { handle, count: current.count }; const state = await rpc('local_close'); onSwitch(state); notice('Demo workspace restored. Reconnect to continue with your local folder and staged edits.'); } catch (error) { notice(error.message); } }; $('#refresh-folder').onclick = async () => { try { canSwitch(); onSwitch(await rpc('local_refresh')); notice('Folder refreshed from disk. Started a new chat with the current files.'); } catch (error) { notice(error.message); } }; function renderReview(patches) { reviewPatches = patches; $('#review-files').replaceChildren(); $('#review-error').textContent = ''; $('#review-description').textContent = `Review changes to ${current.name}. Applying writes them to your local folder.`; for (const patch of patches) { const details = document.createElement('details'), title = document.createElement('summary'), pre = document.createElement('pre'); const kind = patch.kind === 'directory' ? 'New folder' : patch.after === null ? 'Delete' : patch.before === null ? 'Create' : 'Edit'; title.textContent = `${kind} · ${patch.path.slice(1)}`; details.append(title); details.open = patches.length < 5; if (patch.kind !== 'directory') { try { const before = text(patch.before), after = text(patch.after); if (before.length + after.length > 160_000) pre.textContent = 'Large text change. Inspect the file in the editor before applying.'; else { const diff = createTwoFilesPatch(patch.path, patch.path, before, after, 'on disk when read', 'staged', { context: 3 }); for (const line of diff.split('\n')) { const span = document.createElement('span'); span.textContent = line + '\n'; if (line.startsWith('+')) span.className = 'diff-add'; if (line.startsWith('-')) span.className = 'diff-remove'; pre.append(span); } } } catch { pre.textContent = `Binary file · ${patch.before === null ? 0 : atob(patch.before).length} → ${patch.after === null ? 0 : atob(patch.after).length} bytes`; } details.append(pre); } $('#review-files').append(details); } controls(); } $('#review-changes').onclick = async () => { try { canSwitch(); renderReview(await rpc('local_changes')); dialog.showModal(); } catch (error) { notice(error.message); } }; $('#review-close').onclick = () => dialog.close(); dialog.addEventListener('cancel', event => { if (busy) event.preventDefault(); }); $('#review-apply').onclick = async () => { try { // Only this user gesture requests write access. The worker never prompts. if (await handle.requestPermission({ mode: 'readwrite' }) !== 'granted') throw Error('Write access was not granted. Changes are still staged.'); const result = await rpc('local_apply'); dialog.close(); notice(`Applied ${result.applied} change${result.applied === 1 ? '' : 's'} to ${handle.name}.`); } catch (error) { // Application can partially succeed; refresh the review to show only the remaining edits. try { renderReview(await rpc('local_changes')); } catch {} $('#review-error').textContent = error.message; } }; $('#review-discard').onclick = async () => { if (!window.confirm('Discard all staged changes and reload files from disk? Your local files will not be changed.')) return; try { onSwitch(await rpc('local_discard')); dialog.close(); notice('Staged edits discarded. Folder refreshed and chat reset.'); } catch (error) { $('#review-error').textContent = error.message; } }; window.addEventListener('beforeunload', event => { if (current?.count) { event.preventDefault(); event.returnValue = ''; } }); if (new URLSearchParams(location.search).has('local-folder')) notice('Choose Link a folder to select the project you want Pi to work on.'); return { setBusy(value) { busy = value; controls(); }, setState(value) { current = value; if (saved && value) saved.count = value.count; controls(); }, restore(value) { saved = value; controls(); }, get folderName() { return current?.name; }, get active() { return Boolean(current); }, }; }