Repository navigation
Description
Objective
The extension cannot publish a static or TypeScript web app to Deepnote; users have to run deepnote publish from a terminal. After this change, "Deepnote: Publish App…" uploads a local folder as it is (for a built app, its build output, such as dist/) into the app folder of a Deepnote data app project with publishApp from @deepnote/cloud-sync, publishes the data app, then shows the app URL with Open and Copy buttons. A folder inside a synced project publishes to that project after one confirmation. Any other folder publishes to a data app the user picks from the workspace's data app projects, or to a new data app project that "New Data App…" creates, under a name the user enters, as it publishes. That data app is remembered per folder, so republishing takes one confirmation. When app files changed in Deepnote since the last sync, the user chooses "Sync First" (run "Sync with Deepnote Cloud" for that sync root with working files included, bringing Deepnote's versions down to merge before publishing again), "Force" (overwrite them in Deepnote) or cancel. The command is available in trusted workspaces on the desktop extension host, from folder context menus in the file explorer and from the Command Palette.
Scope
- In scope:
- Pinning
@deepnote/cloud0.4.0 and@deepnote/cloud-sync0.3.0, the releases that publish data apps. PublishAppCommandHandlerand its per-folder saved data app in workspace state.- Choosing the data app: the synced project, the saved data app, a data app from the workspace's project list, or a new data app project created while publishing.
- Publishing with progress, the result and error UI, and the divergence choice.
- One lock shared by both sync commands and every publish of a folder inside a sync root, so neither runs while the other does.
- An optional
{ root, allFiles }argument for thedeepnote.syncWithDeepnoteCloudcommand, which "Sync First" uses, and which makes the command resolve itsWorkspaceSyncResult. showPublishedAppUrl, which shows an app URL with Open and Copy.- An E2E suite for publishing against
@deepnote/cloud-mock, and an E2E helper that saves the API token. - Localization, the command and menu contributions, and the README.
- Pinning
- Out of scope:
- Running a build command. The user builds the app first and publishes the build output folder, such as
dist/. - Unpublishing a data app (see Follow-up plans).
- Listing, publishing to, or converting projects that are not data apps. A synced project that is not a data app is refused by
publishApp. - Choosing a Deepnote folder for a new data app project (
create.folderId). New data app projects are created at the workspace root. - Detecting or changing the workspace's "Allow static file sharing in projects" setting. A refused publish step is reported like any other failed operation.
- A dry-run preview of a sync, a sync service shared by commands, and a per-root queue that waits for a running operation. A publish into a synced folder that finds the lock taken is refused, not queued.
- Prompting for
working-files-changedconflicts. They stay skipped. - App items in the Deepnote explorer, "Deepnote: New App…", app templates, and a status-bar item with the app URL.
- Streamlit apps.
- Removing stale files from the app folder (
prune), and publishing to a folder other than_deepnote_static(targetPath). - Merging Deepnote's app-file changes into the local app folder after "Sync First", and publishing again automatically.
- Recognizing a rejected token inside
PublishError, which carries no HTTP status. - Editing or listing saved publish settings outside the publish flow.
- Cancelling an upload in progress.
- Two publishes of the same folder outside any sync root at the same time.
- Coordinating with the Deepnote CLI or another editor window that works on the same sync root.
- The web extension host, and telemetry events.
- Running a build command. The user builds the app first and publishes the build output folder, such as
Relevant context
- Sequencing: this change builds on BLU-6519 (PR feat(cloud): sync a local folder with Deepnote Cloud #601, branch
tomaskislan/blu-6519-sync-a-local-folder-with-deepnote-cloud-from-the-extension), which must merge first. - Preconditions on that branch:
package.jsonpins@deepnote/cloud0.3.0and@deepnote/cloud-sync0.2.0independencies, and@deepnote/cloud-mock0.1.0indevDependencies.src/notebooks/deepnote/cloud/types.tsdeclaresIDeepnoteApiTokenService.getOrPromptToken(resource: Uri): Promise<string | undefined>. It returns the trimmedDEEPNOTE_TOKENfrom the nearest.envbetweenresource(a folder is its own start) and the nearest folder holding.deepnote-sync.json, or, with no such folder, fromresource's own folder only; else the token saved with "Deepnote: Set API Token"; else a token the user enters.undefinedmeans the user cancelled, the URI is not a local file, the workspace is not trusted, or the.envtoken is malformed; the user has already been told.src/notebooks/deepnote/cloud/syncWithDeepnoteCloudCommandHandler.node.tsdefinesSyncWithDeepnoteCloudCommandHandler, which registers two commands, both mapped to[]insrc/commands.ts:Commands.SyncWithDeepnoteCloud('deepnote.syncWithDeepnoteCloud', "Sync with Deepnote Cloud", also in the Deepnote explorer title bar) runs the privatesyncWithDeepnoteCloud(), which picks the root withchooseSyncedRoot(): the only folder holding.deepnote-sync.json, a quick pick when there are several, or an offer to sync a new folder when there is none.Commands.SyncNewFolderWithDeepnoteCloud('deepnote.syncNewFolderWithDeepnoteCloud', "Sync a New Folder with Deepnote Cloud…") runs the privatesyncNewFolderWithDeepnoteCloud(), which picks the root withchooseNewRoot(): a folder dialog, resolved to the sync root that contains the chosen folder.- Both run inside the private
runOneSyncAtATime(sync: () => Promise<void>), which refuses withDeepnoteCloud.syncInProgresswhile the private booleansyncInProgressis set, and otherwise sets it aroundsync. Both then callsyncFolder(root). syncFolder(root)runscheckSyncTarget(root)(the outside-workspace check,findOverlappingRoot, andhasSyncManifestfor the first-sync flag),confirmFirstSync(root)on a first sync,getOrPromptToken(root),saveUnsavedNotebooks(), thenrunSyncWorkspace({ rootDir, baseUrl, token, onConflict, onEvent })insidewindow.withProgress, thenvoid this.showSummary(result). A rejected sync goes toshowCloudError(DeepnoteCloud.syncFailed, error, root). Every step resolvesvoid. Two comments, inchooseSyncedRoot()and aftershowSummary, refer to "the in-progress flag".askConflictlogs and returns'skip'forempty-local-directoryandworking-files-changed, with a comment saying the command sets neitherdeleteMissingNotebooksnorallFiles.- Its unit tests are in
syncWithDeepnoteCloudCommandHandler.node.unit.test.ts. The first test ofsuite('choosing a synced folder'), "syncs the only synced folder without asking", deep-equals the non-functionrunSyncWorkspaceoptions to{ rootDir, baseUrl, token }. Two tests insuite('one sync at a time')expect the information messageA sync with Deepnote Cloud is already in progress.
src/notebooks/deepnote/cloud/cloudErrors.node.tsexportsshowCloudError(message: string, error: unknown, resource?: Uri): Promise<void>. It logs the error. For anApiErrorwith status 401, it showsDeepnoteCloud.apiTokenRejectedForRequestwith a "Set API Token" button forresource. Every other error, a 403 included, showsDeepnoteCloud.errorWithDetail(message, <the error's message>), which reads<message>: <detail>.src/notebooks/deepnote/importClient.node.tsexportsgetApiEndpoint(resource?: Uri): string: thedeepnote.apiUrlsetting without trailing slashes when it is set, elsehttps://api.<deepnote.domain>.src/platform/common/utils/localize.tshasexport namespace DeepnoteCloud, which already includesshowLog("Show Log") andsyncInProgress. It has noandMore.
- Data apps: a Deepnote project whose
projectTypeisdata_appkeeps its app's files below its_deepnote_staticfolder. Publishing the data app lets people with access to the project open it; its editors can always open it, and there is no anonymous or link-only access. A data app calls Deepnote with a short-lived token scoped to the person viewing it, never the publisher's token, so publishing has no API access setting. Publishing requires "Allow static file sharing in projects" in the workspace settings. publishApp(options: PublishAppOptions): Promise<PublishAppResult>(@deepnote/cloud-sync0.3.0):- Options:
dir: string: every file below it is uploaded..envand.env.*files, and files over 100 MiB, are refused.- Exactly one of
projectId: stringandcreate: { name: string; folderId?: string }.createmakes a data app project at the workspace root (or infolderId) only after the local files pass validation, and never updates a sync mirror. baseUrl,token.force?: boolean: overwrite files that changed in Deepnote since the sync mirror last recorded them.syncRoot?: string | false:undefineddiscovers the sync root fromdirwithfindSyncManifestRoot. When the manifest there does not trackprojectId, publishing goes ahead without a mirror.onEvent?: (event: PublishAppEvent) => void, which must not throw.
- Order: validate the local files; resolve the mirror (with
projectId); create the project (withcreate); load the project; refuse a project that is not a data app; check for divergence; delete and re-upload each file; update the mirror; then, only when every file operation succeeded, publish the data app. - Events:
{ kind: 'project-created'; projectId; name }(withcreate, beforepublishing),{ kind: 'publishing'; fileCount; targetPath; projectId }(after the project loads),{ kind: 'file-removed'; path },{ kind: 'file-uploaded'; path }(pathrelative todir),{ kind: 'operation-failed'; operation: 'remove' | 'upload' | 'publish'; path; message },{ kind: 'mirror-skipped'; projectDir },{ kind: 'mirror-incomplete'; syncRoot; failures: string[] }. - Result:
{ projectId, created, targetPath, totalFiles, uploaded, pruned, errors: { path, message }[], syncRoot?, mirrorUpdated, appUrl? }. File failures do not throw; they are listed inerrors. A failed publish step adds{ path: 'project settings', message }toerrorsand emitsoperation-failedwithoperation: 'publish'.appUrl(the data app's URL, ending in/) is set only when every operation succeeded. - Throws
PublishError(reason: 'invalid-input' | 'unreadable-directory' | 'project-unavailable' | 'not-a-data-app') before any file changes: a missing, empty or non-directorydir; a.envfile indir(messageRefusing to publish ".env": environment files may contain secrets…); an oversized file; colliding paths; an unusable sync folder; a project that cannot be created (Could not create data app project "<name>": <cause>) or loaded (Could not load project <id>: <cause>); a project that is not a data app (Project <id> is not a data app project, so it cannot be published. …). Aproject-createdevent can precede aproject-unavailablerejection. - Throws
PublishDivergedError(syncRoot: string, sortedpaths: string[]) unlessforceis set, before any file is deleted or uploaded. It means those app files changed in Deepnote since the sync manifest recorded them. - When the manifest tracks
projectId, each uploaded file is written to<syncRoot>/<project dir>/.files/<path>and recorded with its size, hash andupdatedAtin the manifest'sfilesfor that project; that record is the baseline the divergence check compares with the cloud inventory. - Network calls, all with
Authorization: Bearer <token>:POST /v2/projectswith JSON{ name, projectType: 'data_app', folderId? }(only withcreate):{ project: { id, name, … } }.GET /v2/projects/{id}:{ project: { id, name, files: { path, size, updatedAt }[], dataApp?: { published, url } | null } }.dataAppisnullfor a project that is not a data app.urlmust be a valid URL.DELETE /v2/files?projectId=&path=: 204, or 404 when the file does not exist (treated as success). Sent before each upload.POST /v2/files: multipart fieldsprojectId,path,file. Responds 201{ file: { projectId, path, size?, updatedAt? } }. Whenpathexists, the server stores the file at a generated unique path instead;publishAppdeletes that copy and reports a file error.PATCH /v2/projects/{id}with JSON{ dataApp: { published: true } }:{ project: { id, name, …, dataApp } }. Sent on every publish whose file operations all succeeded. It answers 400 for a project that is not a data app, and 403 when "Allow static file sharing in projects" is off.
- Options:
- Other library APIs:
@deepnote/cloud-sync:findSyncManifestRoot(startDir: string): Promise<string | undefined>returns the nearest folder at or abovestartDirholding.deepnote-sync.json. It rejects when that file is a symbolic link.loadSyncManifest(rootDir: string): Promise<SyncManifest>rejects when the manifest is invalid.SyncManifestis{ version: 1, projects: Record<projectId, { dir: string; notebooks: string[]; contentHash: string; ... }> };diris a POSIX path relative to the sync root.projectFilesDir(projectDir)returns`${projectDir}/.files`.syncWorkspace'sWorkspaceSyncOptions.allFiles?: booleanalso syncs each project's working files; app files are working files under_deepnote_static. Working files follow their project's direction: for a project whose outcome ispulledorunchangedwith no upload retries pending, Deepnote's versions are written to<root>/<project dir>/.files/<path>and counted in the outcome'sfilesDownloaded. Apushedoutcome uploads working files instead and setsfilesUploaded.skipped-conflictanderroroutcomes transfer none.ProjectSyncOutcomeis{ projectId, name, path, action, detail?, filesDownloaded?, filesUploaded?, ... }, withpathrelative to the root.
@deepnote/cloud:listAllProjects(baseUrl: string, token: string): Promise<SyncProject[]>.SyncProjectis{ id, name, folder?: { name, path: { id, name }[] } | null, ... }, wherepathlists the folders from the workspace root. At runtime each project also carries the API'sprojectType(such as'data_app'or'standard'), whichSyncProjectdoes not declare.PROJECT_STATIC_ROOTis'_deepnote_static'.- Values from
@deepnote/cloud-syncand@deepnote/cloudmay be imported only from.node.tsfiles.
- VS Code APIs:
window.showQuickPickhas no pre-selection option, so a current choice is shown first and marked. Notification messages render[label](command:…)as a runnable link. - Workspace trust and the web host: commands that need a trusted desktop host declare
"enablement": "isWorkspaceTrusted && !deepnote.webExtension", asdeepnote.syncWithDeepnoteClouddoes. VS Code hides a disabled command from the Command Palette and greys it out in menus, so menu entries also add the condition to theirwhen.deepnote.webExtensionisfalseinsrc/extension.node.tsandtrueinsrc/extension.web.ts. - Workspace state:
IExtensionContext.workspaceState(aMemento) persists per workspace.src/test/mocks/mementos.tsexports the in-memoryMockMemento. - Paths and helpers: use
getFilePath(uri)(src/platform/common/platform/fs-paths.ts) for filesystem paths, neveruri.fsPath.relativePath(from, to)andbasename(uri)are insrc/platform/vscode-path/resources.ts.Commands.ViewJupyterOutput('deepnote.viewOutput') shows the output channel thatloggerwrites to. - Command conventions: IDs are constants in
Commandsinsrc/platform/common/constants.ts, each mapped inICommandNameArgumentTypeMappinginsrc/commands.ts. Titles aredeepnote.commands.<name>.titleinpackage.nls.json. Desktop services are registered insrc/notebooks/serviceRegistry.node.tswithaddSingleton<IExtensionSyncActivationService>(IExtensionSyncActivationService, <Class>). Existingexplorer/contextentries use the groups"Deepnote"and"Deepnote2". - Testing: unit tests run as ES modules, so sinon cannot stub imported functions; library calls that reach the network go through overridable instance methods, as
SyncWithDeepnoteCloudCommandHandler.runSyncWorkspacedoes. VS Code APIs are stubbed throughmockedVSCodeNamespacesfromsrc/test/vscode-mock.ts. - E2E:
@deepnote/cloud-mockexportsstartMockDeepnoteCloud(options?), which serves a mock of the Deepnote API v2 on a free port of127.0.0.1and resolves{ rootDir, url, stop }. It accepts any Bearer token.- Each project is a directory of
rootDirnamed by its UUID (other directory names are ignored), holding its.deepnotenotebooks, its working files below.files/, and, for a data app project, an empty.data-appmarker file. The project's name comes from its first notebook. GET /v2/projectsandGET /v2/projects/{id}reportprojectTypedata_appfor a project with the marker andstandardotherwise.GET /v2/projects/{id}lists the files below.files/and reportsdataAppas{ published, url: '<mock url>/apps/<id>/' }for a data app andnullotherwise.PATCH /v2/projects/{id}with{ dataApp: { published } }answers 400 for a standard project, and keeps the published state in memory. It does not model the workspace's sharing setting.POST /v2/projectswithprojectType: 'data_app'writes the marker and a placeholder.files/_deepnote_static/index.html.POST /v2/filesstores an upload to an existing path at<stem> (<n>)<ext>, with the lowest freenfrom 1.DELETE /v2/filesanswers 404 for a missing file.
- Each project is a directory of
test/e2e/suite/cloud/syncWithDeepnoteCloud.e2e.test.tswritesdeepnote.apiUrlinto its temp workspace's.vscode/settings.json, opens the folder withopenFolderViaDialog, and saves a token with its localsetApiToken(), which runs "Deepnote: Set API Token" with its localAPI_TOKEN('e2e_mock_token') and throws on aDeepnote rejected…orCould not…notification.selectDeepnoteContextMenu(item, label)intest/e2e/helpers/deepnoteTree.tsright-clicks any ExTesterViewItemand clicks a menu entry.confirmModalDialog(label, { messageIncludes })is intest/e2e/helpers/modals.ts,openActivityBarView(title)intest/e2e/helpers/views.ts, andcopyFixtureIntoDir(dir, fixture)intest/e2e/helpers/fixtures.ts.- The fixture
test/e2e/fixtures/cloud-sync.deepnoteis project "E2E Cloud Sync" (4f0c2a8e-6b1d-4c3e-9a7f-2d5e8b1c0a93), andcloud-sync-analytics-costs.deepnoteis project "Analytics" (8b3e1f6a-2c4d-4e5f-9a7b-1c3d5e7f9a2b). - The
cloudgroup is already in the matrix of.github/workflows/e2e.yml.
Completion criteria
- First publish. Running the command on a folder outside any synced project, with no saved data app, shows one quick pick: "New Data App…" first, then the workspace's
data_appprojects sorted by name, each with its Deepnote folder path. Projects of other types are not listed. Picking a data app saves it for that folder and publishes the folder itself to it. - New data app. Picking "New Data App…" asks for a name and refuses a blank one. The publish creates a data app project with the trimmed name at the workspace root and publishes to it. The new project is saved for that folder once Deepnote reports it created, even when a later step fails, so publishing again does not create a second project. Dismissing the pick or the name prompt saves and publishes nothing.
- Synced project. For a folder inside a project of a sync manifest, the command shows one modal, "Publish to ?", with only "Publish", where
<project>is the last segment of the project'sdir. "Publish" publishes to that project. No project list and no "New Data App…" are shown, and nothing is saved for the folder. When that project is not a data app, the publish fails with thenot-a-data-appmessage and uploads nothing. - Republish. Running the command again on a folder with a saved data app shows one modal, "Publish to ?", with "Publish" and "Change Settings…".
- "Publish" publishes with no further prompts.
- "Change Settings…" shows the data app pick again, with the saved data app first and marked as the current choice.
- Success. A fully successful publish shows the app URL with "Open" (opens it in the browser) and "Copy" (copies it to the clipboard).
- Partial failure. A publish with failed operations, a refused publish step included (such as the 403 when "Allow static file sharing in projects" is off), shows how many failed and offers "Show Log". No URL is shown.
- Divergence. A
PublishDivergedErrorshows a modal listing the changed paths, with "Sync First" and "Force":- "Sync First" runs "Sync with Deepnote Cloud" for
error.syncRootwith working files included, with the command's usual checks, prompts and summary, and does not publish again. When that sync downloaded the project's working files, it shows the folder where Deepnote's versions now are. When the project's sync failed, was skipped, or pushed local notebook edits instead, it says Deepnote's versions were not downloaded, even if other projects synced. - "Force" publishes again with
force: true. - Dismissing the modal stops, leaving local and cloud files unchanged.
- "Sync First" runs "Sync with Deepnote Cloud" for
- One operation at a time. A publish of a folder inside a sync root, started while either sync command or another such publish runs in this window, shows
A sync or app publish with Deepnote Cloud is already in progress.and publishes nothing. Either sync command started while such a publish runs shows the same message. A folder outside any sync root is never refused. The divergence modal appears only after the publish has released the lock. - Sync with a target.
deepnote.syncWithDeepnoteCloudcalled with{ root, allFiles }skips the folder pick, runs every other check of the command forroot, passesallFilestosyncWorkspace, and resolves the sync result, orundefinedwhen it stopped early or failed. Run with no argument, from the Command Palette or the Deepnote explorer title bar, it picks the synced folder as it does without a target and passesallFiles: false, and so does "Sync a New Folder with Deepnote Cloud…". - Errors. A
PublishError, or any other failure, is reported throughshowCloudErrorwith the folder, and no URL is shown. - No token. When no token is available,
publishAppis never called. - Availability. In Restricted Mode and in the web extension host, the command is offered neither in the Command Palette nor in the explorer context menu.
- E2E. The
cloudE2E group publishes against@deepnote/cloud-mockfrom the explorer context menu: a first publish whose pick lists only data apps, a republish with the saved data app, a publish to a new data app, and a divergence resolved with "Force".
Implementation
- Pin the library releases that publish data apps
- Files:
package.json,package-lock.json(modify). - In
dependencies, change@deepnote/cloudto exactly0.4.0and@deepnote/cloud-syncto exactly0.3.0, and updatepackage-lock.jsonwithnpm install.
- Files:
- Share one lock between sync and publish
- Files:
src/notebooks/deepnote/cloud/types.ts(modify)src/notebooks/deepnote/cloud/deepnoteCloudOperationLock.ts(create)src/notebooks/serviceRegistry.node.ts(modify)src/notebooks/deepnote/cloud/syncWithDeepnoteCloudCommandHandler.node.ts(modify)src/notebooks/deepnote/cloud/syncWithDeepnoteCloudCommandHandler.node.unit.test.ts(modify)src/platform/common/utils/localize.ts(modify)
- In
types.ts, addIDeepnoteCloudOperationLock(symbol and interface) with one method,tryAcquire(): Disposable | undefined. Its docstring: marks a sync, or a publish into a synced folder, as running in this window;undefinedwhen one already is; disposing the result releases the lock. - Create the injectable
DeepnoteCloudOperationLock, which implements it with one private booleanheld. Register it inserviceRegistry.node.tswithaddSingleton<IDeepnoteCloudOperationLock>(IDeepnoteCloudOperationLock, DeepnoteCloudOperationLock). - In
SyncWithDeepnoteCloudCommandHandler, injectIDeepnoteCloudOperationLockbetweenIDeepnoteApiTokenServiceandIExtensionContext, and delete thesyncInProgressfield.runOneSyncAtATimebecomes generic,runOneSyncAtATime<T>(sync: () => Promise<T | undefined>): Promise<T | undefined>: whentryAcquire()returnsundefined, it showsDeepnoteCloud.cloudOperationInProgressand resolvesundefined; otherwise it resolves whatsyncresolves and disposes the lease in afinally. Both sync commands keep running inside it. The comments inchooseSyncedRoot()and aftershowSummaryrefer to the lock instead of the in-progress flag. - In
DeepnoteCloud, replacesyncInProgresswithcloudOperationInProgress("A sync or app publish with Deepnote Cloud is already in progress."). - Unit tests: construct the handler with a real
DeepnoteCloudOperationLock, and change the expected message in the twosuite('one sync at a time')tests to the new string. Add to that suite: with the lock already acquired by the test, "Sync with Deepnote Cloud" shows that message and calls neithershowQuickPicknorrunSyncWorkspace; after the test disposes its lease, the command syncs.
- Files:
- Let the sync command take a target folder with working files
- Files:
src/notebooks/deepnote/cloud/types.ts(modify)src/commands.ts(modify)src/notebooks/deepnote/cloud/syncWithDeepnoteCloudCommandHandler.node.ts(modify)src/notebooks/deepnote/cloud/syncWithDeepnoteCloudCommandHandler.node.unit.test.ts(modify)
- In
types.ts, exportinterface SyncWithDeepnoteCloudTarget { root: Uri; allFiles: boolean }, documented as the argument ofdeepnote.syncWithDeepnoteCloudthat syncsrootwithout asking for a folder, withallFilesdocumented as also syncing each project's working files, such as app files. - Map
Commands.SyncWithDeepnoteCloudto[SyncWithDeepnoteCloudTarget | undefined]. - Add the module function
asSyncTarget(arg: unknown): SyncWithDeepnoteCloudTarget | undefined. It returns{ root, allFiles: allFiles === true }whenarg.root instanceof Uri, andundefinedfor any other argument. syncWithDeepnoteCloud(arg?: unknown)resolvesWorkspaceSyncResult | undefined. InsiderunOneSyncAtATime, it usesasSyncTarget(arg)?.root, or else the root fromchooseSyncedRoot(), and resolvessyncFolder(root, target?.allFiles === true), orundefinedwhen there is no root.syncFolder(root, allFiles: boolean)resolvesWorkspaceSyncResult | undefined.checkSyncTarget, the first-sync confirmation and every later step still run for a target root.runSyncWorkspacereceivesallFiles. Aftervoid this.showSummary(result)(still not awaited), it resolvesresult; every early return and a rejected sync resolveundefined.syncNewFolderWithDeepnoteCloud()callssyncFolder(root, false).- In
askConflict,empty-local-directorykeeps itslogger.warnand'skip', under the comment that sync raises it only withdeleteMissingNotebooks, which is never set.working-files-changedreturns'skip'without logging, under the comment that sync raises it only withallFiles, whereoverridewould upload local files over Deepnote's, and that on a skip sync warns which files it kept. - Unit tests:
- The deep-equal in "syncs the only synced folder without asking" now also expects
allFiles: false. - The command handlers are captured with their arguments, and a new
suite('syncing a requested folder')covers:- Calling the "Sync with Deepnote Cloud" handler with
{ root: Uri.file(<ws>/a), allFiles: true }, with a manifest in<ws>/a, opens neither a quick pick nor a dialog, callsrunSyncWorkspacewithrootDir<ws>/aandallFiles: true, and resolves the stubbed result. - The same call with
root<tmp>/outsideshows the outside-workspace warning, requests no token, and resolvesundefined.
- Calling the "Sync with Deepnote Cloud" handler with
- The deep-equal in "syncs the only synced folder without asking" now also expects
- Files:
- Add the strings and the URL helper
- Files:
src/platform/common/utils/localize.ts(modify)src/notebooks/deepnote/cloud/publishedAppUrl.node.ts(create)
- Add to
DeepnoteCloud:andMore(count)("…and {0} more")chooseAppFolderTitle("Choose the app folder to publish")publish("Publish")loadingProjects("Loading Deepnote projects…")loadProjectsFailed("Could not load Deepnote projects")chooseProject("Choose the data app to publish to")currentChoice("Current choice")newDataApp("New Data App…"),newDataAppDescription("Create a data app project and publish to it")newDataAppPrompt("Name of the data app project to create"),newDataAppNameRequired("Enter a name for the data app.")publishSavedPrompt(folder, project)("Publish {0} to {1}?")changeSettings("Change Settings…")publishing(folder)("Publishing {0} to Deepnote…")publishProgress(uploaded, total)("{0} of {1} files uploaded")publishFailed("Publishing the app failed")publishPartiallyFailed(count)("{0} operation(s) failed while publishing the app. If it was already published, it now serves the files that uploaded, and the ones that failed may be missing or out of date. See the log for details.")publishSucceeded(url)("App published: {0}")open("Open"),copy("Copy"),copiedToClipboard("App URL copied")publishMirrorIncomplete(syncRoot)("The app was published, but the synced copy in {0} could not be fully updated. Sync again to refresh it.")publishDiverged(count, paths)("{0} app file(s) changed in Deepnote since the last sync: {1}")publishDivergedDetail("Sync First syncs the whole synced folder, including the working files of every project, so you can merge Deepnote's versions into your app. Force overwrites them in Deepnote.")syncFirst("Sync First"),force("Force")publishSyncedChanges(folder)("Deepnote's versions of the changed app files are now in {0}. Merge them into your app, then publish again.")publishSyncMissedAppFiles(project)("The sync did not download Deepnote's changed app files for {0}, and nothing was published. Fix any errors or skipped conflicts the sync reported, then publish again and choose Sync First.")
- Create
publishedAppUrl.node.tsexporting:- the constant
COPIED_MESSAGE_TIMEOUT_MS = 3000; showPublishedAppUrl(message: string, url: string): Promise<void>. It showswindow.showInformationMessage(message, DeepnoteCloud.open, DeepnoteCloud.copy). "Open" callsenv.openExternal(Uri.parse(url)); "Copy" callsenv.clipboard.writeText(url), thenwindow.setStatusBarMessage(DeepnoteCloud.copiedToClipboard, COPIED_MESSAGE_TIMEOUT_MS).
- the constant
- Files:
- Implement the publish command
- Files:
src/notebooks/deepnote/cloud/publishAppCommandHandler.node.ts(create)src/notebooks/deepnote/cloud/publishAppCommandHandler.node.unit.test.ts(create)src/platform/common/constants.ts,src/commands.ts,src/notebooks/serviceRegistry.node.ts(modify)
- Add
PublishApp = 'deepnote.publishApp'toCommands, mapped to[Uri | undefined]. - Create the injectable
PublishAppCommandHandler, which implementsIExtensionSyncActivationService. InjectIDeepnoteApiTokenService,IDeepnoteCloudOperationLockandIExtensionContext.activate()registersCommands.PublishApp, passing its argument topublishFromCommandonly when it is aUri, and pushes the disposable ontoextensionContext.subscriptions. Register the class inserviceRegistry.node.ts, afterSyncWithDeepnoteCloudCommandHandler. - Module constants:
SETTINGS_STATE_KEY = 'deepnote.publishApp.folderSettings'andMAX_LISTED_PATHS = 10. - Types:
type PublishAppSettings = { projectId: string; projectName: string }, the saved data app, stored inworkspaceStateas aRecord<string, PublishAppSettings>keyed by the folder'sUri.toString().type PublishTarget = PublishAppSettings | { newProjectName: string }.- The exported
type ListedProject = SyncProject & { projectType?: string }, documented as declaring theprojectTypethatlistAllProjectspasses through.
- Seams (protected instance methods that tests override):
listProjects(baseUrl, token): Promise<ListedProject[]>callslistAllProjects;runPublishApp(options)callspublishApp. - The command handler,
publishFromCommand(arg?: Uri).folderNameisbasename(folder).- The folder is
arg, or else the first folder fromwindow.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, canSelectMany: false, defaultUri: workspace.workspaceFolders?.[0]?.uri, title: DeepnoteCloud.chooseAppFolderTitle, openLabel: DeepnoteCloud.publish }). A dismissed dialog stops. token = await getOrPromptToken(folder); stop when it is empty orundefined.- Choose the target (private
chooseTarget(folder, token)):- Synced project.
findSyncedProject(folder)resolves the sync root withfindSyncManifestRoot(getFilePath(folder)), then callsloadSyncManifest; a rejection of either is logged withlogger.warnand treated as no synced project. It computes the folder's root-relative POSIX path withrelativePath. A project matches when that path equals itsdiror starts withdir + '/', and its name is the last segment ofdir. On a match, showwindow.showInformationMessage(DeepnoteCloud.publishSavedPrompt(folderName, projectName), { modal: true }, DeepnoteCloud.publish). "Publish" targets that project; dismissing stops. Nothing is saved. - Saved data app. Otherwise, when a data app is saved for the folder, show
window.showInformationMessage(DeepnoteCloud.publishSavedPrompt(folderName, saved.projectName), { modal: true }, DeepnoteCloud.publish, DeepnoteCloud.changeSettings). "Publish" targets the saved data app; "Change Settings…" chooses a data app with the saved one as the current choice; dismissing stops. - No saved data app. Otherwise, choose a data app with no current choice.
- Synced project.
- Choose a data app (private
chooseProject(folder, token, current?)):- Call
listProjects(getApiEndpoint(folder), token)insidewindow.withProgress({ location: ProgressLocation.Notification, title: DeepnoteCloud.loadingProjects }). A rejection goes tovoid showCloudError(DeepnoteCloud.loadProjectsFailed, error, folder)and stops. - Keep the projects whose
projectTypeis'data_app', sorted by name withlocaleCompare. Each item haslabel: project.name,description: project.folder?.path.map((s) => s.name).join(' / '), and its project ID. The current data app's item getsdetail: DeepnoteCloud.currentChoice. - Show
window.showQuickPickwithplaceHolder: DeepnoteCloud.chooseProjectand the items{ label: DeepnoteCloud.newDataApp, description: DeepnoteCloud.newDataAppDescription }followed by the data apps, with the current data app's item moved first. Dismissing stops without saving. - A picked data app is saved for the folder as
{ projectId, projectName: label }before any upload, and becomes the target. - "New Data App…" shows
window.showInputBox({ title: DeepnoteCloud.newDataApp, prompt: DeepnoteCloud.newDataAppPrompt, validateInput }), wherevalidateInputreturnsDeepnoteCloud.newDataAppNameRequiredfor a blank value. Dismissing stops without saving. The trimmed name becomes the target{ newProjectName }; nothing is saved yet.
- Call
- Publish (private
publish(folder, target, token, force: boolean)):- Resolve
syncRootwithfindSyncManifestRoot(getFilePath(folder)). A rejection is logged withlogger.debugand counts as no sync root;publishAppdiscovers the same sync root fromdirand reports the problem through the rejection handling below. - When
syncRootis set,lease = lock.tryAcquire(). When that isundefined, showDeepnoteCloud.cloudOperationInProgressand stop. Without a sync root, take no lock. - Inside
window.withProgress({ location: ProgressLocation.Notification, title: DeepnoteCloud.publishing(folderName) }), callrunPublishApp({ dir: getFilePath(folder), projectId, baseUrl: getApiEndpoint(folder), token, force, onEvent }), withcreate: { name: target.newProjectName }in place ofprojectIdfor a new data app. Dispose the lease in afinallyaround that call. Everything below runs after the lease is disposed. onEvent:project-createdis logged withlogger.infoand remembered as{ projectId, projectName: event.name };publishingstores the total;file-uploadedreportsDeepnoteCloud.publishProgress(uploaded, total)withincrement: 100 / total;operation-failedgoes tologger.warnas<operation> failed for <path>: <message>;mirror-incompleteis remembered, and each failure goes tologger.warn; every other event goes tologger.debug.- When a
project-createdevent was received, save the created project for the folder, whetherpublishAppresolved or rejected. - On a rejection:
PublishDivergedErrorwith aprojectIdtarget: showwindow.showWarningMessage(DeepnoteCloud.publishDiverged(paths.length, <the first MAX_LISTED_PATHS paths joined with ', ', followed by ' ' and DeepnoteCloud.andMore(rest) when longer>), { modal: true, detail: DeepnoteCloud.publishDivergedDetail }, DeepnoteCloud.syncFirst, DeepnoteCloud.force).- "Sync First" runs
commands.executeCommand<WorkspaceSyncResult | undefined>(Commands.SyncWithDeepnoteCloud, { root: Uri.file(error.syncRoot), allFiles: true })and never publishes again.undefinedstops; the sync command has already told the user why, or the user cancelled. Otherwise,outcomeis the entry ofresult.projectswhoseprojectIdis the target's. When(outcome?.filesDownloaded ?? 0) > 0, showDeepnoteCloud.publishSyncedChanges(workspace.asRelativePath(Uri.joinPath(Uri.file(error.syncRoot), projectFilesDir(outcome.path), PROJECT_STATIC_ROOT)))as an information message. In every other case, showwindow.showWarningMessage(DeepnoteCloud.publishSyncMissedAppFiles(projectName)), whateverresult.successsays about other projects, whereprojectNameis the target'sprojectNamewith every](replaced by] (, so a name from Deepnote cannot form a command link. - "Force" calls
publishagain withforce: true, which takes the lock again. - Dismissing stops.
- "Sync First" runs
- Any other error goes to
void showCloudError(DeepnoteCloud.publishFailed, error, folder).
- On a result:
- when
errors.length > 0, showwindow.showWarningMessage(DeepnoteCloud.publishPartiallyFailed(errors.length), DeepnoteCloud.showLog), not awaited; the button runsCommands.ViewJupyterOutput; - otherwise, when
appUrlis set, callvoid showPublishedAppUrl(DeepnoteCloud.publishSucceeded(appUrl), appUrl); - when a
mirror-incompleteevent was received, also showwindow.showWarningMessage(DeepnoteCloud.publishMirrorIncomplete(<the event's syncRoot>)).
- when
- Resolve
- The folder is
- Unit tests, in
publishAppCommandHandler.node.unit.test.ts:- Fixtures: a subclass overrides
listProjectsandrunPublishAppwith sinon stubs;runPublishAppresolves a result withcreated: falseand no errors by default.IDeepnoteApiTokenServiceis a ts-mockito mock resolving'tok'. The lock is a realDeepnoteCloudOperationLock, andworkspaceStateaMockMemento.window,env,commandsandworkspaceare stubbed throughmockedVSCodeNamespaces;deepnotesettings return their defaults, so the base URL ishttps://api.deepnote.com. The command is invoked through the handler captured fromcommands.registerCommand. Folders live in a temporary directory<tmp>, and setup asserts that no.deepnote-sync.jsonsits above it. The sync-root fixture is<tmp>/root/.deepnote-sync.jsoncontaining{"version":1,"projects":{"p1":{"dir":"Team/Sales","notebooks":[],"contentHash":"x"}}}.listProjectsresolves Ops (p-ops,data_app,folder: null), Notebooks (p-notebooks,standard), Untyped (p-untyped, noprojectType), Sales (p-sales,data_app, folder pathTeam/Q3) and Alpha (p-alpha,data_app). suite('choosing the folder and the settings'):- With no argument, the open dialog gets the options of step 5.1, with
defaultUri<tmp>, and the chosen<tmp>/appis published. A dismissed dialog requests no token and publishes nothing. getOrPromptTokenresolvingundefined: no quick pick, andrunPublishAppis not called.- First publish of
<tmp>/appwith "Sales" picked:listProjectsreceiveshttps://api.deepnote.com; the items are New Data App… (descriptionCreate a data app project and publish to it), Alpha, Ops, and Sales with descriptionTeam / Q3; the non-function options ofrunPublishAppdeep-equal{ dir: <tmp>/app, projectId: 'p-sales', baseUrl: 'https://api.deepnote.com', token: 'tok', force: false }; workspace state holds{ projectId: 'p-sales', projectName: 'Sales' }underUri.file('<tmp>/app').toString(). - A dismissed project pick: nothing is saved, and
runPublishAppis not called. listProjectsrejecting withnew Error('boom'): the error messageCould not load Deepnote projects: boom, andrunPublishAppis not called.- Folder
<tmp>/root/Team/Sales/appwith "Publish": the modal's arguments are['Publish app to Sales?', { modal: true }, 'Publish'],listProjectsis not called,runPublishAppreceivesprojectIdp1, and nothing is saved. Dismissing that modal publishes nothing. - Folder
<tmp>/root/Team/SalesArchive/applists the projects; so does<tmp>/root/Team/Sales/appwhen the manifest contains{. - Saved
p-salesand "Publish": the modal's arguments are['Publish app to Sales?', { modal: true }, 'Publish', 'Change Settings…'], no quick pick opens, andrunPublishAppis called once withp-sales. A dismissed modal opens no quick pick and publishes nothing. - Saved
p-salesand "Change Settings…" with "Ops" picked: the items are Sales with detailCurrent choice, then New Data App…, Alpha and Ops;{ projectId: 'p-ops', projectName: 'Ops' }is saved and published to.
- With no argument, the open dialog gets the options of step 5.1, with
suite('creating a data app'), picking New Data App… and enteringRevenue review:runPublishAppemitting{ kind: 'project-created', projectId: 'p-new', name: 'Revenue review' }and resolving: its non-function options deep-equal{ dir: <tmp>/app, create: { name: 'Revenue review' }, baseUrl, token: 'tok', force: false }, and{ projectId: 'p-new', projectName: 'Revenue review' }is saved.- The same event followed by a rejection with
new PublishError('project-unavailable', 'Could not load project p-new: boom'): the error messagePublishing the app failed: Could not load project p-new: boom, andp-newis saved. - The input box's
validateInputreturnsEnter a name for the data app.for' 'andundefinedfor'Revenue review'. - A dismissed name prompt: nothing is saved, and
runPublishAppis not called.
suite('one operation at a time'):<tmp>/appwhile the test holds the lock: it still publishes.<tmp>/root/Team/Sales/appwhile the test holds the lock: the information messages arePublish app to Sales?andA sync or app publish with Deepnote Cloud is already in progress., andrunPublishAppis not called.- While
runPublishAppruns for<tmp>/root/Team/Sales/app, the test cannot acquire the lock; after the publish settles, it can.
suite('results'):- Events
publishingwithfileCount: 2and twofile-uploaded: the progress reports are{ message: '1 of 2 files uploaded', increment: 50 }and{ message: '2 of 2 files uploaded', increment: 50 }. - A result with
appUrl: 'https://deepnote.com/app/x': the last message isApp published: https://deepnote.com/app/xwith "Open" and "Copy". "Copy" writes that URL to the clipboard and sets the status-bar messageApp URL copiedfor 3000 ms; "Open" callsenv.openExternalwith that URL. - A result with one error: the last message is the warning
1 operation(s) failed while publishing the app. …with "Show Log", no information message is shown, and "Show Log" runsCommands.ViewJupyterOutput. - A
mirror-incompleteevent withsyncRoot: '/sync'and anappUrl: the information messageApp published: …, then the warningThe app was published, but the synced copy in /sync could not be fully updated. Sync again to refresh it. runPublishApprejecting withnew PublishError('invalid-input', 'Refusing to publish ".env"'): the error messagePublishing the app failed: Refusing to publish ".env", and no information message.
- Events
suite('app files changed in Deepnote'), for folder<tmp>/root/Team/Sales/appconfirmed with "Publish", with the firstrunPublishAppcall rejecting withnew PublishDivergedError('<tmp>/root', ['_deepnote_static/a.js']):- "Force": the modal's arguments are
['1 app file(s) changed in Deepnote since the last sync: _deepnote_static/a.js', { modal: true, detail: <publishDivergedDetail> }, 'Sync First', 'Force']; while it is open the test can acquire the lock; the calls'forcevalues are[false, true]. - With 10 paths, the message lists all of them; with 12, it lists the first 10, then
…and 2 more. - Dismissed:
runPublishAppis called once, and the sync command is not run. - "Sync First" (sync results use
root: '<tmp>/root',dryRun: falseanduntrackedFiles: []):{ success: true, projects: [{ projectId: 'p1', name: 'Sales', path: 'Team/Sales', action: 'unchanged', filesDownloaded: 1 }] }: the sync command receives{ root: <tmp>/root, allFiles: true },runPublishAppis called once, and the information message namesroot/Team/Sales/.files/_deepnote_static.success: falsewith p1{ action: 'error', detail: 'export failed' }and{ projectId: 'p2', name: 'Ops', path: 'Ops', action: 'pulled', filesDownloaded: 2 }: the warning ispublishSyncMissedAppFiles('Sales'), andpublishSyncedChangesis not shown.success: truewith p1{ action: 'pushed', filesUploaded: 0 }: the warning ispublishSyncMissedAppFiles('Sales').- Folder
<tmp>/root/sitewith the data appX [Show Log](command:evil)(p-x) picked, and p-xpushed: the warning namesX [Show Log] (command:evil). - The sync command resolving
undefined: the only warning is the divergence modal, no information message follows the confirmation, andrunPublishAppis called once.
- "Force": the modal's arguments are
- Fixtures: a subclass overrides
- Files:
- Test publishing end to end against the mock Deepnote Cloud
- Files:
test/e2e/helpers/apiToken.ts(create),test/e2e/helpers/index.ts(modify)test/e2e/suite/cloud/syncWithDeepnoteCloud.e2e.test.ts(modify)test/e2e/suite/cloud/publishApp.e2e.test.ts(create)
- Move
setApiToken()andAPI_TOKENfromsyncWithDeepnoteCloud.e2e.test.tstotest/e2e/helpers/apiToken.ts, export them fromtest/e2e/helpers/index.ts, and import them in the sync suite. publishApp.e2e.test.ts, indescribe('Deepnote Cloud — publishing an app to the mock Deepnote Cloud'):- Setup:
startMockDeepnoteCloud()with two projects:<rootDir>/4f0c2a8e-6b1d-4c3e-9a7f-2d5e8b1c0a93/holdingcloud-sync.deepnoteand an empty.data-appmarker (data app "E2E Cloud Sync"), and<rootDir>/8b3e1f6a-2c4d-4e5f-9a7b-1c3d5e7f9a2b/holdingcloud-sync-analytics-costs.deepnotewith no marker (standard project "Analytics").- A temp workspace with
.vscode/settings.jsonsettingdeepnote.apiUrlto the mock URL andexplorer.compactFolderstofalse, soSales/appis its own row;site/index.html(<h1>site v1</h1>);newapp/index.html(<h1>new app</h1>); and a sync rootsynced/.deepnote-sync.jsontracking the data app project withdirSales,notebooks: []andcontentHash: 'x', plussynced/Sales/app/index.html(<h1>synced app</h1>). - It opens the folder with
openFolderViaDialogand saves the token withsetApiToken().
- Each publish clears the Notifications Center, opens the file explorer, finds the folder's item (opening its parent folders), runs
selectDeepnoteContextMenu(item, 'Publish App…'), answers the prompts, and waits in the Notifications Center for a message matching^(App published|Publishing the app failed). Any message butApp published…fails the setup with that message. - A quick pick is answered by waiting for its
placeHolder, waiting until the label is listed (recording the listed labels), typing the label, waiting until it is the only match, and confirming. The name prompt is recognized by its titleNew Data App…. - The setup publishes, in order:
site: pick "E2E Cloud Sync" inChoose the data app to publish to.siteagain, after rewritingsite/index.htmlto<h1>site v2</h1>: confirm "Publish" in the modal containingPublish site to E2E Cloud Sync?.newapp: pick "New Data App…" and enterE2E New App. The new project's ID is read from the/apps/<id>/end of the published URL.synced/Sales/app: confirm "Publish" in the modal containingPublish app to Sales?, then wait untilsynced/Sales/.filesshows in the explorer.synced/Sales/appagain, after rewriting the data app's.files/_deepnote_static/index.htmlin the mock to<h1>edited in Deepnote</h1>: confirm "Publish", then "Force" in the modal containingchanged in Deepnote since the last sync.
- Tests:
- The first pick listed exactly
New Data App…andE2E Cloud Sync; the first publish reportedApp published: <mock url>/apps/<data app id>/; the mock's.files/_deepnote_static/index.htmlwas<h1>site v1</h1>; andGET /v2/projects/<data app id>reporteddataApp{ published: true, url: '<mock url>/apps/<data app id>/' }. - The republish reported the same URL, and the mock's file was
<h1>site v2</h1>. - The new data app's ID is neither empty nor one of the two seeded IDs;
GET /v2/projects/<id>reportsname: 'E2E New App'anddataApp{ published: true, url: '<mock url>/apps/<id>/' }; and its.files/_deepnote_static/index.htmlis<h1>new app</h1>. - Both publishes of
synced/Sales/appreported the data app's URL, and the mock's file ended as<h1>synced app</h1>.
- The first pick listed exactly
- Setup:
- Files:
- Contribute the command and document it
- Files:
package.json,package.nls.json,README.md(all modified). contributes.commands:{ "command": "deepnote.publishApp", "title": "%deepnote.commands.publishApp.title%", "category": "Deepnote", "icon": "$(rocket)", "enablement": "isWorkspaceTrusted && !deepnote.webExtension" }, afterdeepnote.syncNewFolderWithDeepnoteCloud, and inpackage.nls.json"deepnote.commands.publishApp.title": "Publish App…".contributes.menus.explorer/context:{ "when": "explorerResourceIsFolder && isWorkspaceTrusted && !deepnote.webExtension", "command": "deepnote.publishApp", "group": "Deepnote" }. The command gets nocommandPaletteentry; itsenablementhides it there.- README:
- Add the Command palette row
Deepnote: Publish App…— "Publish a folder, such as an app's build output, as a Deepnote data app", after theDeepnote: Sync a New Folder with Deepnote Cloud…row. - Add a "Publishing apps" subsection under "Usage", after "Syncing with Deepnote Cloud". It states that:
- the command runs from the Command Palette or from
Publish App…on a folder in the file explorer, uploads the folder to a Deepnote data app project, publishes the app, and shows its URL with "Open" and "Copy"; people with access to the project can open a published app, and there is no anonymous or link-only access; - the folder is published as it is, so build the app first and publish its build output folder, such as
dist/; - each file replaces the app file at the same path in Deepnote, and files deleted from the folder stay published until they are deleted in Deepnote;
- only data app projects can be published to: a folder inside a synced project goes to that project after one confirmation, and fails if it is not a data app; any other folder goes to one of the workspace's data apps, or to a data app project that "New Data App…" creates with the name entered;
- the data app is remembered per folder, so publishing again asks for one confirmation, with "Change Settings…" to choose again;
- a data app calls Deepnote with a short-lived token scoped to the person viewing it, never the publisher's personal token;
- publishing needs "Allow static file sharing in projects" turned on in the workspace settings;
- it uses
DEEPNOTE_TOKENfrom a.envfile in a synced folder above the app folder, or else the token saved withDeepnote: Set API Token; a folder that contains a.envor.env.*file cannot be published, because every published file can be served to the app's viewers; - a publish into a synced folder does not start while a sync or another such publish is in progress;
- for a folder inside a synced project, publishing asks first when app files changed in Deepnote since the last sync or publish: "Force" overwrites them in Deepnote, and "Sync First" syncs the whole synced folder with the working files of every project, downloading Deepnote's versions into the synced project's
.files/_deepnote_staticfolder to merge before publishing again; a project with a working file over 100 MiB fails to sync this way; a folder outside a synced project overwrites app files changed in Deepnote without asking; - publishing needs a trusted workspace and the desktop app.
- the command runs from the Command Palette or from
- Add the Command palette row
- Files:
Manual follow-up
-
Static and TypeScript apps. Precondition: a desktop build; a Deepnote workspace with data app projects enabled, "Allow static file sharing in projects" on, and at least one data app project; a Deepnote API key for it set through "Deepnote: Set API Token"; and a Vite TypeScript app folder whose
npm run buildwritesdist/.- Run
npm run buildin a terminal, then publishdist/with "New Data App…" and a new name. - Change a source file, run
npm run buildagain, and publishdist/again with "Publish" in the confirmation. - Publish a plain folder holding only
index.htmlto an existing data app picked from the list.
Expected: the pick lists only the workspace's data apps. The new data app project appears at the workspace root in Deepnote, and "Open" shows the app. The second publish asks nothing beyond the one confirmation, publishes to the same project, and shows the change. The plain folder is served at its data app's URL.
- Run
-
Refused publishes. Precondition: the same workspace and token.
- Turn "Allow static file sharing in projects" off, and publish a folder to a data app.
- Turn it on again. In a folder synced with "Sync with Deepnote Cloud", publish a folder inside a project that is not a data app.
Expected: the first publish shows
1 operation(s) failed while publishing the app. …with "Show Log", and the log names the failedpublishofproject settingswith Deepnote's 403 message. The second showsPublishing the app failed: Project <id> is not a data app project, so it cannot be published. …, and no file of that project changes in Deepnote. -
Divergence. Precondition: an app folder inside a synced data app project, published once from the extension.
- Edit
_deepnote_static/index.htmlin Deepnote. - Publish locally and choose "Sync First".
Expected: the sync summary appears, and the message names the project's
.files/_deepnote_staticfolder, where the edited file contains the Deepnote edit. Publishing again, without merging, uploads the local folder without a divergence prompt. - Edit
-
Cursor. Precondition: Cursor with the extension's VSIX installed, a Deepnote API key set through "Deepnote: Set API Token", and the built
dist/folder published once from the first item. Action: publishdist/from the file explorer's context menu, confirm with "Publish", then choose "Open" and "Copy" on the result. Expected: the context-menu entry appears on the folder, "Open" shows the app, and "Copy" puts its URL on the clipboard. -
Public documentation. Precondition: write access to the
deepnote/deepnoterepository. Action: open a pull request there that documents "Deepnote: Publish App…" indocs/vscode-extension.md. Expected: the page covers the entry points, publishing a build output folder, data apps and "New Data App…", the per-folder data app, the "Allow static file sharing in projects" requirement, and the divergence choices.
Follow-up plans
- Unpublish a data app from the extension (Linear BLU-6709). Adds a command that unpublishes the data app a folder was published to, as
deepnote unpublishdoes; the app's files stay in the project, and its editors can still open the app. Depends on this plan, whose per-folder data app and synced-project lookup it uses. - Publish a Streamlit file as a Deepnote app. Adds "Deepnote: Publish Streamlit App". It reuses
showPublishedAppUrl, theDeepnoteCloud.publishstring and theIDeepnoteCloudOperationLockfrom this plan. Depends on this plan. - Preview a sync before applying it. Shows the planned pulls, pushes, conflicts and working-file transfers from a dry run, including for "Sync First". Depends on this plan.
Activity
linear-code commentedon Nov 7, 2026
BLU-6520
[-]Publish a folder as a Deepnote app[/-][+]Publish a folder as a Deepnote data app[/+]