From 107e34db7d44f2c08ddd8af27deb566e9aadb679 Mon Sep 17 00:00:00 2001 From: KCM Date: Thu, 7 May 2026 11:34:22 -0500 Subject: [PATCH 1/2] feat: share urls. --- playwright/share-workspace.spec.ts | 287 ++++++++++++++++++ src/app.js | 79 ++++- src/index.html | 15 + src/modules/app-core/app-bindings-startup.js | 76 ++++- .../app-core/github-workflows-setup.js | 1 + src/modules/app-core/github-workflows.js | 31 ++ src/modules/app-core/workspace-share-codec.js | 137 +++++++++ .../workspace/workspaces-drawer/drawer.js | 34 +++ src/styles/preview-controls.css | 4 + 9 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 playwright/share-workspace.spec.ts create mode 100644 src/modules/app-core/workspace-share-codec.js diff --git a/playwright/share-workspace.spec.ts b/playwright/share-workspace.spec.ts new file mode 100644 index 0000000..e0fc983 --- /dev/null +++ b/playwright/share-workspace.spec.ts @@ -0,0 +1,287 @@ +import { expect, test } from '@playwright/test' +import { + appEntryPath, + connectByotWithSingleRepo, + resetWorkbenchStorage, + setComponentEditorSource, + waitForAppReady, +} from './helpers/app-test-helpers.js' +const installClipboardCapture = async (page: import('@playwright/test').Page) => { + await page.addInitScript(() => { + let copied = '' + + Object.defineProperty(window, '__shareClipboardText', { + configurable: true, + get: () => copied, + set: value => { + copied = typeof value === 'string' ? value : String(value ?? '') + }, + }) + + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: async (text: string) => { + ;(window as { __shareClipboardText?: string }).__shareClipboardText = + typeof text === 'string' ? text : String(text ?? '') + }, + readText: async () => { + return (window as { __shareClipboardText?: string }).__shareClipboardText ?? '' + }, + }, + }) + }) +} + +const getWorkspaceRecords = async (page: import('@playwright/test').Page) => { + return page.evaluate(async () => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('knighted-develop-workspaces') + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const transaction = db.transaction('prWorkspaces', 'readonly') + const store = transaction.objectStore('prWorkspaces') + const request = store.getAll() + + return await new Promise>>((resolve, reject) => { + request.onsuccess = () => { + const value = Array.isArray(request.result) ? request.result : [] + resolve(value as Array>) + } + request.onerror = () => reject(request.error) + }) + } finally { + db.close() + } + }) +} + +const encodeSharePayload = async ( + page: import('@playwright/test').Page, + snapshot: Record, +) => { + return page.evaluate(async sourceSnapshot => { + const toBase64Url = (bytes: Uint8Array) => { + const chunkSize = 0x8000 + let binary = '' + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)) + } + + const base64 = btoa(binary) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') + } + + const source = JSON.stringify({ + version: 1, + compression: 'gzip', + createdAt: Date.now(), + snapshot: sourceSnapshot, + }) + + const sourceBytes = new TextEncoder().encode(source) + const sourceStream = new Blob([sourceBytes]).stream() + const compressedStream = sourceStream.pipeThrough(new CompressionStream('gzip')) + const compressedBuffer = await new Response(compressedStream).arrayBuffer() + return toBase64Url(new Uint8Array(compressedBuffer)) + }, snapshot) +} + +test('share button is shown for local workspace and copies a share URL', async ({ + page, +}) => { + await installClipboardCapture(page) + await waitForAppReady(page, `${appEntryPath}`) + + await page.getByRole('button', { name: 'Workspaces' }).click() + + const shareButton = page + .locator('#workspaces-drawer') + .getByRole('button', { name: 'Share local workspace snapshot' }) + await expect(shareButton).toBeVisible() + + await setComponentEditorSource( + page, + "export const App = () =>
Shared local snapshot
", + ) + + await shareButton.click() + await expect(page.getByRole('status', { name: 'App status' })).toContainText( + 'Share link copied', + ) + + await expect + .poll(async () => { + return page.evaluate(() => { + return (window as { __shareClipboardText?: string }).__shareClipboardText ?? '' + }) + }) + .not.toBe('') + + const copiedUrl = await page.evaluate(() => { + return (window as { __shareClipboardText?: string }).__shareClipboardText ?? '' + }) + + const copiedPayload = new URL(copiedUrl).searchParams.get('sws') + expect(copiedPayload).toBeTruthy() +}) + +test('share button appears in workspaces drawer and not in editor controls', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + + await expect( + page + .locator('#editor-header-component') + .getByRole('button', { name: 'Share local workspace snapshot' }), + ).toHaveCount(0) + + await page.getByRole('button', { name: 'Workspaces' }).click() + + await expect( + page + .locator('#workspaces-drawer') + .getByRole('button', { name: 'Share local workspace snapshot' }), + ).toBeVisible() +}) + +test('share button is hidden in drawer for non-Local repository filter', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + + await page.getByRole('button', { name: 'Workspaces' }).click() + + const repositoryFilter = page.getByLabel('Workspace repository filter') + await expect(repositoryFilter).toHaveValue('knightedcodemonkey/develop') + + const drawerShareButton = page + .locator('#workspaces-drawer') + .getByRole('button', { name: 'Share local workspace snapshot' }) + await expect(drawerShareButton).toBeHidden() +}) + +test('loads shared URL snapshot into IDB as a new local workspace and clears URL param', async ({ + page, +}) => { + await installClipboardCapture(page) + await resetWorkbenchStorage(page) + + const sharedSnapshot = { + id: 'external-source-id', + workspaceScope: 'repository', + repo: 'knightedcodemonkey/develop', + base: 'main', + head: 'shared/feature-branch', + prNumber: 123, + prTitle: 'Imported snapshot', + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'entry', + name: 'SharedEntry.tsx', + path: 'src/components/SharedEntry.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Shared import content
', + }, + ], + activeTabId: 'entry', + createdAt: Date.now() - 1000, + lastModified: Date.now() - 1000, + schemaVersion: 1, + } + + const encodedPayload = await encodeSharePayload(page, sharedSnapshot) + await waitForAppReady(page, `${appEntryPath}?sws=${encodeURIComponent(encodedPayload)}`) + + await expect + .poll(() => { + const currentUrl = new URL(page.url()) + return currentUrl.searchParams.has('sws') + }) + .toBe(false) + + await expect + .poll(async () => { + const records = await getWorkspaceRecords(page) + const imported = records.find(record => { + if (!record || typeof record !== 'object') { + return false + } + + const tabs = Array.isArray(record.tabs) ? record.tabs : [] + return tabs.some(tab => { + if (!tab || typeof tab !== 'object') { + return false + } + + return ( + typeof tab.content === 'string' && + tab.content.includes('Shared import content') + ) + }) + }) + + if (!imported || typeof imported !== 'object') { + return null + } + + const tabs = Array.isArray(imported.tabs) ? imported.tabs : [] + const firstTab = tabs[0] && typeof tabs[0] === 'object' ? tabs[0] : null + return { + workspaceScope: + typeof imported.workspaceScope === 'string' ? imported.workspaceScope : '', + repo: typeof imported.repo === 'string' ? imported.repo : '', + prNumber: imported.prNumber, + prContextState: + typeof imported.prContextState === 'string' ? imported.prContextState : '', + hasImportedContent: tabs.some(tab => { + if (!tab || typeof tab !== 'object') { + return false + } + + return ( + typeof tab.content === 'string' && + tab.content.includes('Shared import content') + ) + }), + firstTabContent: + firstTab && typeof firstTab.content === 'string' ? firstTab.content : '', + } + }) + .toEqual({ + workspaceScope: 'local', + repo: '', + prNumber: null, + prContextState: 'inactive', + hasImportedContent: true, + firstTabContent: expect.any(String), + }) +}) + +test('invalid shared payload does not crash app and keeps URL param for retry', async ({ + page, +}) => { + await installClipboardCapture(page) + await resetWorkbenchStorage(page) + + await waitForAppReady(page, `${appEntryPath}?sws=this-is-not-valid`) + + await expect(page.getByRole('button', { name: 'Open tab App.tsx' })).toBeVisible() + + await expect + .poll(() => { + const currentUrl = new URL(page.url()) + return currentUrl.searchParams.get('sws') + }) + .toBe('this-is-not-valid') +}) diff --git a/src/app.js b/src/app.js index 835ba5b..c9beb93 100644 --- a/src/app.js +++ b/src/app.js @@ -52,6 +52,11 @@ import { createPrContextStateChangeHandler } from './modules/app-core/pr-context import { createWorkspaceContextStatusController } from './modules/app-core/workspace-context-status-controller.js' import { createWorkspaceRecordAppliedHandler } from './modules/app-core/workspace-record-applied-handler.js' import { createGitHubChatWorkspaceActions } from './modules/app-core/github-chat-workspace-actions.js' +import { + encodeWorkspaceSharePayload, + isNativeWorkspaceShareCodecSupported, + workspaceShareParam, +} from './modules/app-core/workspace-share-codec.js' import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' import { createGitHubChatDrawer } from './modules/github/chat/drawer.js' import { createGitHubByotControls } from './modules/github/byot-controls.js' @@ -147,6 +152,7 @@ const workspacesClose = document.getElementById('workspaces-close') const workspacesStatus = document.getElementById('workspaces-status') const workspacesRepository = document.getElementById('workspaces-repository') const workspacesInitialize = document.getElementById('workspaces-initialize') +const workspacesShare = document.getElementById('workspaces-share') const workspacesNew = document.getElementById('workspaces-new') const workspacesSelect = document.getElementById('workspaces-select') const workspacesOpen = document.getElementById('workspaces-open') @@ -426,6 +432,7 @@ let workspacePrContextState = 'inactive' let workspacePrNumber = null let workspaceRepositoryFullName = '' let workspaceScopeMarker = 'local' +const workspaceScopeListeners = new Set() let activeWorkspacePersistedPrTitle = '' let activeWorkspacePersistedHeadBranch = '' let hasObservedActivePrContextInSession = false @@ -437,8 +444,31 @@ let workspaceContextStatusController = { } const toWorkspaceScopeMarker = value => (value === 'repository' ? 'repository' : 'local') + +const notifyWorkspaceScopeChanged = () => { + for (const listener of workspaceScopeListeners) { + listener(workspaceScopeMarker) + } +} + +const onWorkspaceScopeChange = listener => { + if (typeof listener !== 'function') { + return () => {} + } + + workspaceScopeListeners.add(listener) + return () => { + workspaceScopeListeners.delete(listener) + } +} + const setWorkspaceScopeMarker = nextScope => { - workspaceScopeMarker = toWorkspaceScopeMarker(nextScope) + const nextMarker = toWorkspaceScopeMarker(nextScope) + const didChangeScope = workspaceScopeMarker !== nextMarker + workspaceScopeMarker = nextMarker + if (didChangeScope) { + notifyWorkspaceScopeChanged() + } workspaceContextStatusController.render() } @@ -456,7 +486,11 @@ const setActiveWorkspaceRecordId = nextValue => { activeWorkspacePersistedPrTitle = '' activeWorkspacePersistedHeadBranch = '' workspaceRepositoryFullName = '' + const didChangeScope = workspaceScopeMarker !== 'local' workspaceScopeMarker = 'local' + if (didChangeScope) { + notifyWorkspaceScopeChanged() + } } workspaceContextStatusController.render() } @@ -957,6 +991,41 @@ const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } = }, }) +const maxWorkspaceShareUrlLength = 8000 + +const shareCurrentLocalWorkspace = async () => { + if (!clipboardSupported) { + throw new Error('Clipboard API is not available in this browser context.') + } + + if (!isNativeWorkspaceShareCodecSupported()) { + throw new Error('Native compression is not supported in this browser context.') + } + + if (workspaceScopeMarker !== 'local') { + throw new Error('Share is only available for local workspaces.') + } + + await flushWorkspaceSave({ preserveRecordId: true }) + const snapshot = buildWorkspaceRecordSnapshot() + if (!snapshot || typeof snapshot !== 'object') { + throw new Error('Could not prepare workspace snapshot.') + } + + const encodedPayload = await encodeWorkspaceSharePayload(snapshot) + const sharedUrl = new URL(window.location.href) + sharedUrl.searchParams.set(workspaceShareParam, encodedPayload) + const sharedUrlText = sharedUrl.toString() + + if (sharedUrlText.length > maxWorkspaceShareUrlLength) { + throw new Error('Workspace is too large for a URL.') + } + + await navigator.clipboard.writeText(sharedUrlText) + setStatus('Share link copied', 'neutral') + showAppToast('Share link copied to clipboard.') +} + editedIndicatorVisibilityController.setRefreshHandlers({ syncHeaderLabels, renderWorkspaceTabs, @@ -1159,6 +1228,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ workspacesStatus, workspacesRepository, workspacesInitialize, + workspacesShare, workspacesNew, workspacesSelect, workspacesOpen, @@ -1287,6 +1357,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ confirmAction: options => confirmAction(options), setStatus, showAppToast, + shareCurrentLocalWorkspace, ...githubChatWorkspaceActions, scheduleRender: () => { if ( @@ -1500,16 +1571,22 @@ bindAppEventsAndStart({ addWorkspaceTab, syncHeaderLabels, renderWorkspaceTabs, + refreshLocalContextOptions, + applyWorkspaceRecord, + buildWorkspaceRecordSnapshot, updateRenderModeEditability, loadPreferredWorkspaceContext, getActiveWorkspaceTab, isStyleWorkspaceTab, + getWorkspaceScopeMarker: () => workspaceScopeMarker, + onWorkspaceScopeChange, setActiveWorkspaceTab, workspaceTabsState, getPrimaryStyleWorkspaceTab, syncDiagnosticsDrawerLayout, workspaceSaveController, workspaceStorage, + createWorkspaceRecordId, bindWorkspaceMetadataPersistence, setHasCompletedInitialWorkspaceBootstrap: value => (hasCompletedInitialWorkspaceBootstrap = value), diff --git a/src/index.html b/src/index.html index 9fd480d..4c30df0 100644 --- a/src/index.html +++ b/src/index.html @@ -785,6 +785,21 @@

Workspaces

> Initialize + diff --git a/src/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js index c89242c..5d927fb 100644 --- a/src/modules/app-core/app-bindings-startup.js +++ b/src/modules/app-core/app-bindings-startup.js @@ -1,3 +1,8 @@ +import { + decodeWorkspaceSharePayload, + workspaceShareParam, +} from './workspace-share-codec.js' + const bindAppEventsAndStart = ({ editorUi, diagnosticsUi, @@ -75,6 +80,8 @@ const bindAppEventsAndStart = ({ addWorkspaceTab, syncHeaderLabels, renderWorkspaceTabs, + refreshLocalContextOptions, + applyWorkspaceRecord, updateRenderModeEditability, loadPreferredWorkspaceContext, getActiveWorkspaceTab, @@ -117,6 +124,62 @@ const bindAppEventsAndStart = ({ previewBackground, initializeCodeEditors, } = startup + + const toShareableWorkspaceRecord = snapshot => { + if (!snapshot || typeof snapshot !== 'object') { + return null + } + + const nextTabs = Array.isArray(snapshot.tabs) ? snapshot.tabs : [] + if (nextTabs.length === 0) { + return null + } + + return { + ...snapshot, + id: typeof snapshot.id === 'string' && snapshot.id.trim() ? snapshot.id.trim() : '', + workspaceScope: 'local', + repo: '', + base: '', + head: '', + prNumber: null, + prTitle: '', + prContextState: 'inactive', + workspaceKey: '', + lastModified: Date.now(), + createdAt: Date.now(), + } + } + + const clearWorkspaceShareParamFromUrl = () => { + const currentUrl = new URL(window.location.href) + currentUrl.searchParams.delete(workspaceShareParam) + window.history.replaceState(window.history.state, '', currentUrl.toString()) + } + + const importWorkspaceFromShareUrl = async () => { + const currentUrl = new URL(window.location.href) + const encodedPayload = currentUrl.searchParams.get(workspaceShareParam) + if (!encodedPayload) { + return false + } + + const decodedSnapshot = await decodeWorkspaceSharePayload(encodedPayload) + const importedRecord = toShareableWorkspaceRecord(decodedSnapshot) + if (!importedRecord) { + throw new Error('Shared workspace payload is missing a valid tab snapshot.') + } + + const savedWorkspace = await workspaceStorage.upsertWorkspace(importedRecord) + const didApply = await applyWorkspaceRecord(savedWorkspace, { silent: false }) + + if (didApply) { + clearWorkspaceShareParamFromUrl() + await refreshLocalContextOptions() + } + + return didApply + } const clearComponentSource = () => { setJsxSource('') clearDiagnosticsScope('component') @@ -484,7 +547,18 @@ const bindAppEventsAndStart = ({ renderRuntime.setStyleCompiling(false) setCdnLoading(true) previewBackground.initializePreviewBackgroundPicker() - const workspaceRestoreReady = loadPreferredWorkspaceContext().catch(() => { + const workspaceRestoreReady = (async () => { + try { + const didImportSharedWorkspace = await importWorkspaceFromShareUrl() + if (didImportSharedWorkspace) { + return + } + } catch { + setStatus('Could not import shared workspace context.', 'error') + } + + await loadPreferredWorkspaceContext() + })().catch(() => { setStatus('Could not restore local workspace context.', 'neutral') }) void initializeCodeEditors().then(async () => { diff --git a/src/modules/app-core/github-workflows-setup.js b/src/modules/app-core/github-workflows-setup.js index 1674e78..6bd863a 100644 --- a/src/modules/app-core/github-workflows-setup.js +++ b/src/modules/app-core/github-workflows-setup.js @@ -55,6 +55,7 @@ const createGitHubWorkflowsSetup = ({ confirmAction: actions.confirmAction, setStatus: actions.setStatus, showAppToast: actions.showAppToast, + shareCurrentLocalWorkspace: actions.shareCurrentLocalWorkspace, getActiveWorkspaceTabContext: actions.getActiveWorkspaceTabContext, getWorkspaceTabContexts: actions.getWorkspaceTabContexts, applyWorkspaceTabContent: actions.applyWorkspaceTabContent, diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index 41ac7dc..876a169 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -43,6 +43,7 @@ const initializeGitHubWorkflows = ({ workspacesStatus, workspacesRepository, workspacesInitialize, + workspacesShare, workspacesNew, workspacesSelect, workspacesOpen, @@ -84,6 +85,7 @@ const initializeGitHubWorkflows = ({ confirmAction, setStatus, showAppToast, + shareCurrentLocalWorkspace, getActiveWorkspaceTabContext, getWorkspaceTabContexts, applyWorkspaceTabContent, @@ -427,6 +429,7 @@ const initializeGitHubWorkflows = ({ repositorySelect: workspacesRepository, getActiveWorkspaceId: () => getActiveWorkspaceRecordId(), initializeButton: workspacesInitialize, + shareButton: workspacesShare, newButton: workspacesNew, selectInput: workspacesSelect, openButton: workspacesOpen, @@ -460,6 +463,34 @@ const initializeGitHubWorkflows = ({ return 'right' }, onRefreshRequested: listLocalContextRecords, + onShareCurrentWorkspace: async repositoryFilter => { + const normalizedFilter = + typeof repositoryFilter === 'string' ? repositoryFilter.trim() : '' + const isLocalFilter = !normalizedFilter || normalizedFilter === '__local__' + if (!isLocalFilter) { + workspacesDrawerController?.setStatus( + 'Share is only available for Local workspaces.', + 'error', + ) + return false + } + + if (typeof shareCurrentLocalWorkspace !== 'function') { + workspacesDrawerController?.setStatus('Share is currently unavailable.', 'error') + return false + } + + try { + await shareCurrentLocalWorkspace() + workspacesDrawerController?.setStatus('Share link copied.', 'neutral') + return true + } catch (error) { + const message = + error instanceof Error ? error.message : 'Could not share workspace.' + workspacesDrawerController?.setStatus(`Share failed: ${message}`, 'error') + return false + } + }, onInitializeWorkspace: async repositoryFilter => { const normalizedFilter = typeof repositoryFilter === 'string' ? repositoryFilter.trim() : '' diff --git a/src/modules/app-core/workspace-share-codec.js b/src/modules/app-core/workspace-share-codec.js new file mode 100644 index 0000000..46e3dc4 --- /dev/null +++ b/src/modules/app-core/workspace-share-codec.js @@ -0,0 +1,137 @@ +const workspaceShareParam = 'sws' +const workspaceShareSchemaVersion = 1 +const workspaceShareCompression = 'gzip' + +const isNativeWorkspaceShareCodecSupported = () => { + return ( + typeof CompressionStream === 'function' && + typeof DecompressionStream === 'function' && + typeof TextEncoder === 'function' && + typeof TextDecoder === 'function' && + typeof btoa === 'function' && + typeof atob === 'function' + ) +} + +const uint8ArrayToBase64 = bytes => { + let binary = '' + const chunkSize = 0x8000 + + for (let index = 0; index < bytes.length; index += chunkSize) { + const chunk = bytes.subarray(index, index + chunkSize) + binary += String.fromCharCode(...chunk) + } + + return btoa(binary) +} + +const base64ToUint8Array = base64 => { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + + return bytes +} + +const toBase64Url = bytes => { + const base64 = uint8ArrayToBase64(bytes) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +const fromBase64Url = value => { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const remainder = normalized.length % 4 + const padded = remainder > 0 ? normalized + '='.repeat(4 - remainder) : normalized + return base64ToUint8Array(padded) +} + +const streamToUint8Array = async stream => { + const buffer = await new Response(stream).arrayBuffer() + return new Uint8Array(buffer) +} + +const compressText = async text => { + const encoder = new TextEncoder() + const sourceBytes = encoder.encode(text) + const sourceStream = new Blob([sourceBytes]).stream() + const compressedStream = sourceStream.pipeThrough( + new CompressionStream(workspaceShareCompression), + ) + + return streamToUint8Array(compressedStream) +} + +const decompressText = async bytes => { + const sourceStream = new Blob([bytes]).stream() + const decompressedStream = sourceStream.pipeThrough( + new DecompressionStream(workspaceShareCompression), + ) + const decompressedBytes = await streamToUint8Array(decompressedStream) + const decoder = new TextDecoder() + return decoder.decode(decompressedBytes) +} + +const encodeWorkspaceSharePayload = async snapshot => { + if (!isNativeWorkspaceShareCodecSupported()) { + throw new Error('Native compression is not supported in this browser context.') + } + + if (!snapshot || typeof snapshot !== 'object') { + throw new TypeError('Workspace snapshot must be an object.') + } + + const envelope = { + version: workspaceShareSchemaVersion, + compression: workspaceShareCompression, + createdAt: Date.now(), + snapshot, + } + + const serialized = JSON.stringify(envelope) + const compressed = await compressText(serialized) + return toBase64Url(compressed) +} + +const decodeWorkspaceSharePayload = async encodedPayload => { + if (!isNativeWorkspaceShareCodecSupported()) { + throw new Error('Native compression is not supported in this browser context.') + } + + if (typeof encodedPayload !== 'string' || encodedPayload.trim().length === 0) { + throw new TypeError('Workspace share payload must be a non-empty string.') + } + + let parsed = null + try { + const compressedBytes = fromBase64Url(encodedPayload.trim()) + const serialized = await decompressText(compressedBytes) + parsed = JSON.parse(serialized) + } catch { + throw new Error('Workspace share payload is invalid or corrupted.') + } + + const version = Number(parsed?.version) + if (version !== workspaceShareSchemaVersion) { + throw new Error('Workspace share payload schema is not supported.') + } + + if (parsed?.compression !== workspaceShareCompression) { + throw new Error('Workspace share payload compression is not supported.') + } + + if (!parsed?.snapshot || typeof parsed.snapshot !== 'object') { + throw new Error('Workspace share payload snapshot is invalid.') + } + + return parsed.snapshot +} + +export { + decodeWorkspaceSharePayload, + encodeWorkspaceSharePayload, + isNativeWorkspaceShareCodecSupported, + workspaceShareParam, +} diff --git a/src/modules/workspace/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js index 6206677..7b8b83e 100644 --- a/src/modules/workspace/workspaces-drawer/drawer.js +++ b/src/modules/workspace/workspaces-drawer/drawer.js @@ -48,6 +48,7 @@ export const createWorkspacesDrawer = ({ repositorySelect, getActiveWorkspaceId, initializeButton, + shareButton, newButton, selectInput, openButton, @@ -59,6 +60,7 @@ export const createWorkspacesDrawer = ({ getSelectedRepositoryFilter, onRepositoryFilterChange, onRefreshRequested, + onShareCurrentWorkspace, onInitializeWorkspace, onCreateWorkspace, onOpenSelected, @@ -144,6 +146,15 @@ export const createWorkspacesDrawer = ({ } const updateActions = () => { + const normalizedRepositoryFilter = getNormalizedRepositoryFilter( + selectedRepositoryFilter, + ) + const selectedRepositoryContextValue = + repositorySelect instanceof HTMLSelectElement + ? getNormalizedRepositoryFilter(repositorySelect.value) + : normalizedRepositoryFilter + const isLocalRepositoryContext = + selectedRepositoryContextValue === localRepositoryFilterValue const normalizedSelectedId = selectInput instanceof HTMLSelectElement ? toSafeText(selectInput.value) @@ -168,6 +179,7 @@ export const createWorkspacesDrawer = ({ isSelectedLocalWorkspace && isSelectedWorkspaceNonPr const canCreateWorkspace = typeof onCreateWorkspace === 'function' + const canShareWorkspace = typeof onShareCurrentWorkspace === 'function' const canInitializeWorkspace = typeof onInitializeWorkspace === 'function' const hasStoredWorkspaces = currentUiState === drawerUiState.localWithWorkspaces || @@ -200,6 +212,12 @@ export const createWorkspacesDrawer = ({ newButton.disabled = !canCreateWorkspace } + if (shareButton instanceof HTMLButtonElement) { + const showShare = isLocalRepositoryContext + shareButton.toggleAttribute('hidden', !showShare) + shareButton.disabled = !showShare || !canShareWorkspace + } + if (openButton instanceof HTMLButtonElement) { openButton.disabled = !hasSelection } @@ -520,6 +538,22 @@ export const createWorkspacesDrawer = ({ closeDrawer() }) + shareButton?.addEventListener('click', async () => { + if (typeof onShareCurrentWorkspace !== 'function') { + return + } + + const normalizedRepositoryFilter = getNormalizedRepositoryFilter( + selectedRepositoryFilter, + ) + + try { + await onShareCurrentWorkspace(normalizedRepositoryFilter) + } catch { + setStatus('Could not share workspace.', 'error') + } + }) + openButton?.addEventListener('click', async () => { const id = toSafeText(selectedId) if (!id || typeof onOpenSelected !== 'function') { diff --git a/src/styles/preview-controls.css b/src/styles/preview-controls.css index 29e6f74..69b86c9 100644 --- a/src/styles/preview-controls.css +++ b/src/styles/preview-controls.css @@ -149,6 +149,10 @@ place-content: center; } +.icon-button[hidden] { + display: none; +} + .icon-button:hover { background: var(--surface-control-hover); } From f54148d5ee30f18dd9e4ce6ef4681ccadfe62046 Mon Sep 17 00:00:00 2001 From: KCM Date: Thu, 7 May 2026 11:54:36 -0500 Subject: [PATCH 2/2] refactor: address pr comments. --- .github/instructions/pr-review.md | 1 + src/app.js | 48 +++--------- src/modules/app-core/app-bindings-startup.js | 67 ++-------------- src/modules/app-core/github-workflows.js | 1 - .../app-core/workspace-share-action.js | 52 +++++++++++++ src/modules/app-core/workspace-share-codec.js | 75 +++++++++++++++++- .../app-core/workspace-share-url-import.js | 76 +++++++++++++++++++ 7 files changed, 219 insertions(+), 101 deletions(-) create mode 100644 src/modules/app-core/workspace-share-action.js create mode 100644 src/modules/app-core/workspace-share-url-import.js diff --git a/.github/instructions/pr-review.md b/.github/instructions/pr-review.md index 4abca1d..6f05751 100644 --- a/.github/instructions/pr-review.md +++ b/.github/instructions/pr-review.md @@ -23,6 +23,7 @@ You are reviewing changes for @knighted/develop. Be concise, technical, and spec ## What to verify +- No changes reintroduce cross-workspace overwrite/delete behavior. - No generated artifacts are edited (dist/, coverage/, test-results/). - Duplicated logic paths are avoided when a shared helper/module already exists; prefer reusing the established implementation. - CDN import/fallback behavior is not bypassed with ad hoc URLs in feature modules. diff --git a/src/app.js b/src/app.js index c9beb93..5bdd8bb 100644 --- a/src/app.js +++ b/src/app.js @@ -52,11 +52,7 @@ import { createPrContextStateChangeHandler } from './modules/app-core/pr-context import { createWorkspaceContextStatusController } from './modules/app-core/workspace-context-status-controller.js' import { createWorkspaceRecordAppliedHandler } from './modules/app-core/workspace-record-applied-handler.js' import { createGitHubChatWorkspaceActions } from './modules/app-core/github-chat-workspace-actions.js' -import { - encodeWorkspaceSharePayload, - isNativeWorkspaceShareCodecSupported, - workspaceShareParam, -} from './modules/app-core/workspace-share-codec.js' +import { createShareCurrentLocalWorkspace } from './modules/app-core/workspace-share-action.js' import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' import { createGitHubChatDrawer } from './modules/github/chat/drawer.js' import { createGitHubByotControls } from './modules/github/byot-controls.js' @@ -991,40 +987,14 @@ const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } = }, }) -const maxWorkspaceShareUrlLength = 8000 - -const shareCurrentLocalWorkspace = async () => { - if (!clipboardSupported) { - throw new Error('Clipboard API is not available in this browser context.') - } - - if (!isNativeWorkspaceShareCodecSupported()) { - throw new Error('Native compression is not supported in this browser context.') - } - - if (workspaceScopeMarker !== 'local') { - throw new Error('Share is only available for local workspaces.') - } - - await flushWorkspaceSave({ preserveRecordId: true }) - const snapshot = buildWorkspaceRecordSnapshot() - if (!snapshot || typeof snapshot !== 'object') { - throw new Error('Could not prepare workspace snapshot.') - } - - const encodedPayload = await encodeWorkspaceSharePayload(snapshot) - const sharedUrl = new URL(window.location.href) - sharedUrl.searchParams.set(workspaceShareParam, encodedPayload) - const sharedUrlText = sharedUrl.toString() - - if (sharedUrlText.length > maxWorkspaceShareUrlLength) { - throw new Error('Workspace is too large for a URL.') - } - - await navigator.clipboard.writeText(sharedUrlText) - setStatus('Share link copied', 'neutral') - showAppToast('Share link copied to clipboard.') -} +const shareCurrentLocalWorkspace = createShareCurrentLocalWorkspace({ + clipboardSupported, + getWorkspaceScopeMarker: () => workspaceScopeMarker, + flushWorkspaceSave, + buildWorkspaceRecordSnapshot, + setStatus, + showAppToast, +}) editedIndicatorVisibilityController.setRefreshHandlers({ syncHeaderLabels, diff --git a/src/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js index 5d927fb..9e1b869 100644 --- a/src/modules/app-core/app-bindings-startup.js +++ b/src/modules/app-core/app-bindings-startup.js @@ -1,7 +1,4 @@ -import { - decodeWorkspaceSharePayload, - workspaceShareParam, -} from './workspace-share-codec.js' +import { createWorkspaceShareUrlImporter } from './workspace-share-url-import.js' const bindAppEventsAndStart = ({ editorUi, @@ -90,6 +87,7 @@ const bindAppEventsAndStart = ({ getPrimaryStyleWorkspaceTab, workspaceSaveController, workspaceStorage, + createWorkspaceRecordId, syncDiagnosticsDrawerLayout, setHasCompletedInitialWorkspaceBootstrap, } = workspaceUi @@ -125,61 +123,12 @@ const bindAppEventsAndStart = ({ initializeCodeEditors, } = startup - const toShareableWorkspaceRecord = snapshot => { - if (!snapshot || typeof snapshot !== 'object') { - return null - } - - const nextTabs = Array.isArray(snapshot.tabs) ? snapshot.tabs : [] - if (nextTabs.length === 0) { - return null - } - - return { - ...snapshot, - id: typeof snapshot.id === 'string' && snapshot.id.trim() ? snapshot.id.trim() : '', - workspaceScope: 'local', - repo: '', - base: '', - head: '', - prNumber: null, - prTitle: '', - prContextState: 'inactive', - workspaceKey: '', - lastModified: Date.now(), - createdAt: Date.now(), - } - } - - const clearWorkspaceShareParamFromUrl = () => { - const currentUrl = new URL(window.location.href) - currentUrl.searchParams.delete(workspaceShareParam) - window.history.replaceState(window.history.state, '', currentUrl.toString()) - } - - const importWorkspaceFromShareUrl = async () => { - const currentUrl = new URL(window.location.href) - const encodedPayload = currentUrl.searchParams.get(workspaceShareParam) - if (!encodedPayload) { - return false - } - - const decodedSnapshot = await decodeWorkspaceSharePayload(encodedPayload) - const importedRecord = toShareableWorkspaceRecord(decodedSnapshot) - if (!importedRecord) { - throw new Error('Shared workspace payload is missing a valid tab snapshot.') - } - - const savedWorkspace = await workspaceStorage.upsertWorkspace(importedRecord) - const didApply = await applyWorkspaceRecord(savedWorkspace, { silent: false }) - - if (didApply) { - clearWorkspaceShareParamFromUrl() - await refreshLocalContextOptions() - } - - return didApply - } + const importWorkspaceFromShareUrl = createWorkspaceShareUrlImporter({ + workspaceStorage, + applyWorkspaceRecord, + refreshLocalContextOptions, + createWorkspaceRecordId, + }) const clearComponentSource = () => { setJsxSource('') clearDiagnosticsScope('component') diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index 876a169..983cc4f 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -482,7 +482,6 @@ const initializeGitHubWorkflows = ({ try { await shareCurrentLocalWorkspace() - workspacesDrawerController?.setStatus('Share link copied.', 'neutral') return true } catch (error) { const message = diff --git a/src/modules/app-core/workspace-share-action.js b/src/modules/app-core/workspace-share-action.js new file mode 100644 index 0000000..951fb04 --- /dev/null +++ b/src/modules/app-core/workspace-share-action.js @@ -0,0 +1,52 @@ +import { + encodeWorkspaceSharePayload, + isNativeWorkspaceShareCodecSupported, + workspaceShareParam, +} from './workspace-share-codec.js' + +const defaultMaxWorkspaceShareUrlLength = 8000 + +const createShareCurrentLocalWorkspace = ({ + clipboardSupported, + getWorkspaceScopeMarker, + flushWorkspaceSave, + buildWorkspaceRecordSnapshot, + setStatus, + showAppToast, + maxWorkspaceShareUrlLength = defaultMaxWorkspaceShareUrlLength, +} = {}) => { + return async () => { + if (!clipboardSupported) { + throw new Error('Clipboard API is not available in this browser context.') + } + + if (!isNativeWorkspaceShareCodecSupported()) { + throw new Error('Native compression is not supported in this browser context.') + } + + if (getWorkspaceScopeMarker?.() !== 'local') { + throw new Error('Share is only available for local workspaces.') + } + + await flushWorkspaceSave({ preserveRecordId: true }) + const snapshot = buildWorkspaceRecordSnapshot() + if (!snapshot || typeof snapshot !== 'object') { + throw new Error('Could not prepare workspace snapshot.') + } + + const encodedPayload = await encodeWorkspaceSharePayload(snapshot) + const sharedUrl = new URL(window.location.href) + sharedUrl.searchParams.set(workspaceShareParam, encodedPayload) + const sharedUrlText = sharedUrl.toString() + + if (sharedUrlText.length > maxWorkspaceShareUrlLength) { + throw new Error('Workspace is too large for a URL.') + } + + await navigator.clipboard.writeText(sharedUrlText) + setStatus('Share link copied', 'neutral') + showAppToast('Share link copied to clipboard.') + } +} + +export { createShareCurrentLocalWorkspace } diff --git a/src/modules/app-core/workspace-share-codec.js b/src/modules/app-core/workspace-share-codec.js index 46e3dc4..72a254e 100644 --- a/src/modules/app-core/workspace-share-codec.js +++ b/src/modules/app-core/workspace-share-codec.js @@ -1,6 +1,9 @@ const workspaceShareParam = 'sws' const workspaceShareSchemaVersion = 1 const workspaceShareCompression = 'gzip' +const maxWorkspaceShareEncodedPayloadLength = 8192 +const maxWorkspaceShareDecodedBytes = 1024 * 1024 +const maxWorkspaceShareExpansionRatio = 100 const isNativeWorkspaceShareCodecSupported = () => { return ( @@ -53,6 +56,60 @@ const streamToUint8Array = async stream => { return new Uint8Array(buffer) } +const streamToUint8ArrayWithLimit = async ({ + stream, + maxBytes, + compressedBytesLength, + maxExpansionRatio, +}) => { + const reader = stream.getReader() + const chunks = [] + let totalBytes = 0 + + try { + while (true) { + // Sequential reads are required for Web Streams reader consumption. + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read() + if (done) { + break + } + + if (!(value instanceof Uint8Array)) { + continue + } + + totalBytes += value.byteLength + if (totalBytes > maxBytes) { + throw new Error('Workspace share payload exceeds maximum decoded size.') + } + + if ( + typeof compressedBytesLength === 'number' && + compressedBytesLength > 0 && + typeof maxExpansionRatio === 'number' && + maxExpansionRatio > 0 && + totalBytes > compressedBytesLength * maxExpansionRatio + ) { + throw new Error('Workspace share payload expansion ratio is too large.') + } + + chunks.push(value) + } + } finally { + reader.releaseLock() + } + + const bytes = new Uint8Array(totalBytes) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + + return bytes +} + const compressText = async text => { const encoder = new TextEncoder() const sourceBytes = encoder.encode(text) @@ -69,7 +126,12 @@ const decompressText = async bytes => { const decompressedStream = sourceStream.pipeThrough( new DecompressionStream(workspaceShareCompression), ) - const decompressedBytes = await streamToUint8Array(decompressedStream) + const decompressedBytes = await streamToUint8ArrayWithLimit({ + stream: decompressedStream, + maxBytes: maxWorkspaceShareDecodedBytes, + compressedBytesLength: bytes?.byteLength ?? 0, + maxExpansionRatio: maxWorkspaceShareExpansionRatio, + }) const decoder = new TextDecoder() return decoder.decode(decompressedBytes) } @@ -92,7 +154,12 @@ const encodeWorkspaceSharePayload = async snapshot => { const serialized = JSON.stringify(envelope) const compressed = await compressText(serialized) - return toBase64Url(compressed) + const encoded = toBase64Url(compressed) + if (encoded.length > maxWorkspaceShareEncodedPayloadLength) { + throw new Error('Workspace share payload is too large.') + } + + return encoded } const decodeWorkspaceSharePayload = async encodedPayload => { @@ -104,6 +171,10 @@ const decodeWorkspaceSharePayload = async encodedPayload => { throw new TypeError('Workspace share payload must be a non-empty string.') } + if (encodedPayload.trim().length > maxWorkspaceShareEncodedPayloadLength) { + throw new Error('Workspace share payload exceeds maximum encoded length.') + } + let parsed = null try { const compressedBytes = fromBase64Url(encodedPayload.trim()) diff --git a/src/modules/app-core/workspace-share-url-import.js b/src/modules/app-core/workspace-share-url-import.js new file mode 100644 index 0000000..79146d9 --- /dev/null +++ b/src/modules/app-core/workspace-share-url-import.js @@ -0,0 +1,76 @@ +import { + decodeWorkspaceSharePayload, + workspaceShareParam, +} from './workspace-share-codec.js' + +const toShareableWorkspaceRecord = ({ snapshot, createWorkspaceRecordId }) => { + if (!snapshot || typeof snapshot !== 'object') { + return null + } + + if (typeof createWorkspaceRecordId !== 'function') { + throw new Error('Workspace import id generator is unavailable.') + } + + const nextTabs = Array.isArray(snapshot.tabs) ? snapshot.tabs : [] + if (nextTabs.length === 0) { + return null + } + + return { + ...snapshot, + id: createWorkspaceRecordId(), + workspaceScope: 'local', + repo: '', + base: '', + head: '', + prNumber: null, + prTitle: '', + prContextState: 'inactive', + workspaceKey: '', + lastModified: Date.now(), + createdAt: Date.now(), + } +} + +const clearWorkspaceShareParamFromUrl = () => { + const currentUrl = new URL(window.location.href) + currentUrl.searchParams.delete(workspaceShareParam) + window.history.replaceState(window.history.state, '', currentUrl.toString()) +} + +const createWorkspaceShareUrlImporter = ({ + workspaceStorage, + applyWorkspaceRecord, + refreshLocalContextOptions, + createWorkspaceRecordId, +} = {}) => { + return async () => { + const currentUrl = new URL(window.location.href) + const encodedPayload = currentUrl.searchParams.get(workspaceShareParam) + if (!encodedPayload) { + return false + } + + const decodedSnapshot = await decodeWorkspaceSharePayload(encodedPayload) + const importedRecord = toShareableWorkspaceRecord({ + snapshot: decodedSnapshot, + createWorkspaceRecordId, + }) + if (!importedRecord) { + throw new Error('Shared workspace payload is missing a valid tab snapshot.') + } + + const savedWorkspace = await workspaceStorage.upsertWorkspace(importedRecord) + const didApply = await applyWorkspaceRecord(savedWorkspace, { silent: false }) + + if (didApply) { + clearWorkspaceShareParamFromUrl() + await refreshLocalContextOptions() + } + + return didApply + } +} + +export { createWorkspaceShareUrlImporter }