Skip to content

Publish a folder as a Deepnote data app #608

Open

Description

@tkislan

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/cloud 0.4.0 and @deepnote/cloud-sync 0.3.0, the releases that publish data apps.
    • PublishAppCommandHandler and 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 the deepnote.syncWithDeepnoteCloud command, which "Sync First" uses, and which makes the command resolve its WorkspaceSyncResult.
    • 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.
  • 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-changed conflicts. 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.

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.json pins @deepnote/cloud 0.3.0 and @deepnote/cloud-sync 0.2.0 in dependencies, and @deepnote/cloud-mock 0.1.0 in devDependencies.
    • src/notebooks/deepnote/cloud/types.ts declares IDeepnoteApiTokenService.getOrPromptToken(resource: Uri): Promise<string | undefined>. It returns the trimmed DEEPNOTE_TOKEN from the nearest .env between resource (a folder is its own start) and the nearest folder holding .deepnote-sync.json, or, with no such folder, from resource's own folder only; else the token saved with "Deepnote: Set API Token"; else a token the user enters. undefined means the user cancelled, the URI is not a local file, the workspace is not trusted, or the .env token is malformed; the user has already been told.
    • src/notebooks/deepnote/cloud/syncWithDeepnoteCloudCommandHandler.node.ts defines SyncWithDeepnoteCloudCommandHandler, which registers two commands, both mapped to [] in src/commands.ts:
      • Commands.SyncWithDeepnoteCloud ('deepnote.syncWithDeepnoteCloud', "Sync with Deepnote Cloud", also in the Deepnote explorer title bar) runs the private syncWithDeepnoteCloud(), which picks the root with chooseSyncedRoot(): 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 private syncNewFolderWithDeepnoteCloud(), which picks the root with chooseNewRoot(): a folder dialog, resolved to the sync root that contains the chosen folder.
      • Both run inside the private runOneSyncAtATime(sync: () => Promise<void>), which refuses with DeepnoteCloud.syncInProgress while the private boolean syncInProgress is set, and otherwise sets it around sync. Both then call syncFolder(root).
      • syncFolder(root) runs checkSyncTarget(root) (the outside-workspace check, findOverlappingRoot, and hasSyncManifest for the first-sync flag), confirmFirstSync(root) on a first sync, getOrPromptToken(root), saveUnsavedNotebooks(), then runSyncWorkspace({ rootDir, baseUrl, token, onConflict, onEvent }) inside window.withProgress, then void this.showSummary(result). A rejected sync goes to showCloudError(DeepnoteCloud.syncFailed, error, root). Every step resolves void. Two comments, in chooseSyncedRoot() and after showSummary, refer to "the in-progress flag".
      • askConflict logs and returns 'skip' for empty-local-directory and working-files-changed, with a comment saying the command sets neither deleteMissingNotebooks nor allFiles.
      • Its unit tests are in syncWithDeepnoteCloudCommandHandler.node.unit.test.ts. The first test of suite('choosing a synced folder'), "syncs the only synced folder without asking", deep-equals the non-function runSyncWorkspace options to { rootDir, baseUrl, token }. Two tests in suite('one sync at a time') expect the information message A sync with Deepnote Cloud is already in progress.
    • src/notebooks/deepnote/cloud/cloudErrors.node.ts exports showCloudError(message: string, error: unknown, resource?: Uri): Promise<void>. It logs the error. For an ApiError with status 401, it shows DeepnoteCloud.apiTokenRejectedForRequest with a "Set API Token" button for resource. Every other error, a 403 included, shows DeepnoteCloud.errorWithDetail(message, <the error's message>), which reads <message>: <detail>.
    • src/notebooks/deepnote/importClient.node.ts exports getApiEndpoint(resource?: Uri): string: the deepnote.apiUrl setting without trailing slashes when it is set, else https://api.<deepnote.domain>.
    • src/platform/common/utils/localize.ts has export namespace DeepnoteCloud, which already includes showLog ("Show Log") and syncInProgress. It has no andMore.
  • Data apps: a Deepnote project whose projectType is data_app keeps its app's files below its _deepnote_static folder. 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-sync 0.3.0):
    • Options:
      • dir: string: every file below it is uploaded. .env and .env.* files, and files over 100 MiB, are refused.
      • Exactly one of projectId: string and create: { name: string; folderId?: string }. create makes a data app project at the workspace root (or in folderId) 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: undefined discovers the sync root from dir with findSyncManifestRoot. When the manifest there does not track projectId, 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 (with create); 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 } (with create, before publishing), { kind: 'publishing'; fileCount; targetPath; projectId } (after the project loads), { kind: 'file-removed'; path }, { kind: 'file-uploaded'; path } (path relative to dir), { 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 in errors. A failed publish step adds { path: 'project settings', message } to errors and emits operation-failed with operation: '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-directory dir; a .env file in dir (message Refusing 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. …). A project-created event can precede a project-unavailable rejection.
    • Throws PublishDivergedError (syncRoot: string, sorted paths: string[]) unless force is 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 and updatedAt in the manifest's files for that project; that record is the baseline the divergence check compares with the cloud inventory.
    • Network calls, all with Authorization: Bearer <token>:
      • POST /v2/projects with JSON { name, projectType: 'data_app', folderId? } (only with create): { project: { id, name, … } }.
      • GET /v2/projects/{id}: { project: { id, name, files: { path, size, updatedAt }[], dataApp?: { published, url } | null } }. dataApp is null for a project that is not a data app. url must 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 fields projectId, path, file. Responds 201 { file: { projectId, path, size?, updatedAt? } }. When path exists, the server stores the file at a generated unique path instead; publishApp deletes 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.
  • Other library APIs:
    • @deepnote/cloud-sync:
      • findSyncManifestRoot(startDir: string): Promise<string | undefined> returns the nearest folder at or above startDir holding .deepnote-sync.json. It rejects when that file is a symbolic link.
      • loadSyncManifest(rootDir: string): Promise<SyncManifest> rejects when the manifest is invalid. SyncManifest is { version: 1, projects: Record<projectId, { dir: string; notebooks: string[]; contentHash: string; ... }> }; dir is a POSIX path relative to the sync root.
      • projectFilesDir(projectDir) returns `${projectDir}/.files`.
      • syncWorkspace's WorkspaceSyncOptions.allFiles?: boolean also 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 is pulled or unchanged with no upload retries pending, Deepnote's versions are written to <root>/<project dir>/.files/<path> and counted in the outcome's filesDownloaded. A pushed outcome uploads working files instead and sets filesUploaded. skipped-conflict and error outcomes transfer none. ProjectSyncOutcome is { projectId, name, path, action, detail?, filesDownloaded?, filesUploaded?, ... }, with path relative to the root.
    • @deepnote/cloud: listAllProjects(baseUrl: string, token: string): Promise<SyncProject[]>. SyncProject is { id, name, folder?: { name, path: { id, name }[] } | null, ... }, where path lists the folders from the workspace root. At runtime each project also carries the API's projectType (such as 'data_app' or 'standard'), which SyncProject does not declare. PROJECT_STATIC_ROOT is '_deepnote_static'.
    • Values from @deepnote/cloud-sync and @deepnote/cloud may be imported only from .node.ts files.
  • VS Code APIs: window.showQuickPick has 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", as deepnote.syncWithDeepnoteCloud does. VS Code hides a disabled command from the Command Palette and greys it out in menus, so menu entries also add the condition to their when. deepnote.webExtension is false in src/extension.node.ts and true in src/extension.web.ts.
  • Workspace state: IExtensionContext.workspaceState (a Memento) persists per workspace. src/test/mocks/mementos.ts exports the in-memory MockMemento.
  • Paths and helpers: use getFilePath(uri) (src/platform/common/platform/fs-paths.ts) for filesystem paths, never uri.fsPath. relativePath(from, to) and basename(uri) are in src/platform/vscode-path/resources.ts. Commands.ViewJupyterOutput ('deepnote.viewOutput') shows the output channel that logger writes to.
  • Command conventions: IDs are constants in Commands in src/platform/common/constants.ts, each mapped in ICommandNameArgumentTypeMapping in src/commands.ts. Titles are deepnote.commands.<name>.title in package.nls.json. Desktop services are registered in src/notebooks/serviceRegistry.node.ts with addSingleton<IExtensionSyncActivationService>(IExtensionSyncActivationService, <Class>). Existing explorer/context entries 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.runSyncWorkspace does. VS Code APIs are stubbed through mockedVSCodeNamespaces from src/test/vscode-mock.ts.
  • E2E:
    • @deepnote/cloud-mock exports startMockDeepnoteCloud(options?), which serves a mock of the Deepnote API v2 on a free port of 127.0.0.1 and resolves { rootDir, url, stop }. It accepts any Bearer token.
      • Each project is a directory of rootDir named by its UUID (other directory names are ignored), holding its .deepnote notebooks, its working files below .files/, and, for a data app project, an empty .data-app marker file. The project's name comes from its first notebook.
      • GET /v2/projects and GET /v2/projects/{id} report projectType data_app for a project with the marker and standard otherwise. GET /v2/projects/{id} lists the files below .files/ and reports dataApp as { published, url: '<mock url>/apps/<id>/' } for a data app and null otherwise. 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/projects with projectType: 'data_app' writes the marker and a placeholder .files/_deepnote_static/index.html.
      • POST /v2/files stores an upload to an existing path at <stem> (<n>)<ext>, with the lowest free n from 1. DELETE /v2/files answers 404 for a missing file.
    • test/e2e/suite/cloud/syncWithDeepnoteCloud.e2e.test.ts writes deepnote.apiUrl into its temp workspace's .vscode/settings.json, opens the folder with openFolderViaDialog, and saves a token with its local setApiToken(), which runs "Deepnote: Set API Token" with its local API_TOKEN ('e2e_mock_token') and throws on a Deepnote rejected… or Could not… notification.
    • selectDeepnoteContextMenu(item, label) in test/e2e/helpers/deepnoteTree.ts right-clicks any ExTester ViewItem and clicks a menu entry. confirmModalDialog(label, { messageIncludes }) is in test/e2e/helpers/modals.ts, openActivityBarView(title) in test/e2e/helpers/views.ts, and copyFixtureIntoDir(dir, fixture) in test/e2e/helpers/fixtures.ts.
    • The fixture test/e2e/fixtures/cloud-sync.deepnote is project "E2E Cloud Sync" (4f0c2a8e-6b1d-4c3e-9a7f-2d5e8b1c0a93), and cloud-sync-analytics-costs.deepnote is project "Analytics" (8b3e1f6a-2c4d-4e5f-9a7b-1c3d5e7f9a2b).
    • The cloud group 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_app projects 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's dir. "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 the not-a-data-app message 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 PublishDivergedError shows a modal listing the changed paths, with "Sync First" and "Force":
    • "Sync First" runs "Sync with Deepnote Cloud" for error.syncRoot with 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.
  • 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.syncWithDeepnoteCloud called with { root, allFiles } skips the folder pick, runs every other check of the command for root, passes allFiles to syncWorkspace, and resolves the sync result, or undefined when 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 passes allFiles: false, and so does "Sync a New Folder with Deepnote Cloud…".
  • Errors. A PublishError, or any other failure, is reported through showCloudError with the folder, and no URL is shown.
  • No token. When no token is available, publishApp is 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 cloud E2E group publishes against @deepnote/cloud-mock from 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

  1. Pin the library releases that publish data apps
    • Files: package.json, package-lock.json (modify).
    • In dependencies, change @deepnote/cloud to exactly 0.4.0 and @deepnote/cloud-sync to exactly 0.3.0, and update package-lock.json with npm install.
  2. 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, add IDeepnoteCloudOperationLock (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; undefined when one already is; disposing the result releases the lock.
    • Create the injectable DeepnoteCloudOperationLock, which implements it with one private boolean held. Register it in serviceRegistry.node.ts with addSingleton<IDeepnoteCloudOperationLock>(IDeepnoteCloudOperationLock, DeepnoteCloudOperationLock).
    • In SyncWithDeepnoteCloudCommandHandler, inject IDeepnoteCloudOperationLock between IDeepnoteApiTokenService and IExtensionContext, and delete the syncInProgress field. runOneSyncAtATime becomes generic, runOneSyncAtATime<T>(sync: () => Promise<T | undefined>): Promise<T | undefined>: when tryAcquire() returns undefined, it shows DeepnoteCloud.cloudOperationInProgress and resolves undefined; otherwise it resolves what sync resolves and disposes the lease in a finally. Both sync commands keep running inside it. The comments in chooseSyncedRoot() and after showSummary refer to the lock instead of the in-progress flag.
    • In DeepnoteCloud, replace syncInProgress with cloudOperationInProgress ("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 two suite('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 neither showQuickPick nor runSyncWorkspace; after the test disposes its lease, the command syncs.
  3. 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, export interface SyncWithDeepnoteCloudTarget { root: Uri; allFiles: boolean }, documented as the argument of deepnote.syncWithDeepnoteCloud that syncs root without asking for a folder, with allFiles documented as also syncing each project's working files, such as app files.
    • Map Commands.SyncWithDeepnoteCloud to [SyncWithDeepnoteCloudTarget | undefined].
    • Add the module function asSyncTarget(arg: unknown): SyncWithDeepnoteCloudTarget | undefined. It returns { root, allFiles: allFiles === true } when arg.root instanceof Uri, and undefined for any other argument.
    • syncWithDeepnoteCloud(arg?: unknown) resolves WorkspaceSyncResult | undefined. Inside runOneSyncAtATime, it uses asSyncTarget(arg)?.root, or else the root from chooseSyncedRoot(), and resolves syncFolder(root, target?.allFiles === true), or undefined when there is no root.
    • syncFolder(root, allFiles: boolean) resolves WorkspaceSyncResult | undefined. checkSyncTarget, the first-sync confirmation and every later step still run for a target root. runSyncWorkspace receives allFiles. After void this.showSummary(result) (still not awaited), it resolves result; every early return and a rejected sync resolve undefined. syncNewFolderWithDeepnoteCloud() calls syncFolder(root, false).
    • In askConflict, empty-local-directory keeps its logger.warn and 'skip', under the comment that sync raises it only with deleteMissingNotebooks, which is never set. working-files-changed returns 'skip' without logging, under the comment that sync raises it only with allFiles, where override would 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, calls runSyncWorkspace with rootDir <ws>/a and allFiles: true, and resolves the stubbed result.
        • The same call with root <tmp>/outside shows the outside-workspace warning, requests no token, and resolves undefined.
  4. 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.ts exporting:
      • the constant COPIED_MESSAGE_TIMEOUT_MS = 3000;
      • showPublishedAppUrl(message: string, url: string): Promise<void>. It shows window.showInformationMessage(message, DeepnoteCloud.open, DeepnoteCloud.copy). "Open" calls env.openExternal(Uri.parse(url)); "Copy" calls env.clipboard.writeText(url), then window.setStatusBarMessage(DeepnoteCloud.copiedToClipboard, COPIED_MESSAGE_TIMEOUT_MS).
  5. 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' to Commands, mapped to [Uri | undefined].
    • Create the injectable PublishAppCommandHandler, which implements IExtensionSyncActivationService. Inject IDeepnoteApiTokenService, IDeepnoteCloudOperationLock and IExtensionContext. activate() registers Commands.PublishApp, passing its argument to publishFromCommand only when it is a Uri, and pushes the disposable onto extensionContext.subscriptions. Register the class in serviceRegistry.node.ts, after SyncWithDeepnoteCloudCommandHandler.
    • Module constants: SETTINGS_STATE_KEY = 'deepnote.publishApp.folderSettings' and MAX_LISTED_PATHS = 10.
    • Types:
      • type PublishAppSettings = { projectId: string; projectName: string }, the saved data app, stored in workspaceState as a Record<string, PublishAppSettings> keyed by the folder's Uri.toString().
      • type PublishTarget = PublishAppSettings | { newProjectName: string }.
      • The exported type ListedProject = SyncProject & { projectType?: string }, documented as declaring the projectType that listAllProjects passes through.
    • Seams (protected instance methods that tests override): listProjects(baseUrl, token): Promise<ListedProject[]> calls listAllProjects; runPublishApp(options) calls publishApp.
    • The command handler, publishFromCommand(arg?: Uri). folderName is basename(folder).
      1. The folder is arg, or else the first folder from window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, canSelectMany: false, defaultUri: workspace.workspaceFolders?.[0]?.uri, title: DeepnoteCloud.chooseAppFolderTitle, openLabel: DeepnoteCloud.publish }). A dismissed dialog stops.
      2. token = await getOrPromptToken(folder); stop when it is empty or undefined.
      3. Choose the target (private chooseTarget(folder, token)):
        • Synced project. findSyncedProject(folder) resolves the sync root with findSyncManifestRoot(getFilePath(folder)), then calls loadSyncManifest; a rejection of either is logged with logger.warn and treated as no synced project. It computes the folder's root-relative POSIX path with relativePath. A project matches when that path equals its dir or starts with dir + '/', and its name is the last segment of dir. On a match, show window.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.
      4. Choose a data app (private chooseProject(folder, token, current?)):
        • Call listProjects(getApiEndpoint(folder), token) inside window.withProgress({ location: ProgressLocation.Notification, title: DeepnoteCloud.loadingProjects }). A rejection goes to void showCloudError(DeepnoteCloud.loadProjectsFailed, error, folder) and stops.
        • Keep the projects whose projectType is 'data_app', sorted by name with localeCompare. Each item has label: project.name, description: project.folder?.path.map((s) => s.name).join(' / '), and its project ID. The current data app's item gets detail: DeepnoteCloud.currentChoice.
        • Show window.showQuickPick with placeHolder: DeepnoteCloud.chooseProject and 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 }), where validateInput returns DeepnoteCloud.newDataAppNameRequired for a blank value. Dismissing stops without saving. The trimmed name becomes the target { newProjectName }; nothing is saved yet.
      5. Publish (private publish(folder, target, token, force: boolean)):
        • Resolve syncRoot with findSyncManifestRoot(getFilePath(folder)). A rejection is logged with logger.debug and counts as no sync root; publishApp discovers the same sync root from dir and reports the problem through the rejection handling below.
        • When syncRoot is set, lease = lock.tryAcquire(). When that is undefined, show DeepnoteCloud.cloudOperationInProgress and stop. Without a sync root, take no lock.
        • Inside window.withProgress({ location: ProgressLocation.Notification, title: DeepnoteCloud.publishing(folderName) }), call runPublishApp({ dir: getFilePath(folder), projectId, baseUrl: getApiEndpoint(folder), token, force, onEvent }), with create: { name: target.newProjectName } in place of projectId for a new data app. Dispose the lease in a finally around that call. Everything below runs after the lease is disposed.
        • onEvent: project-created is logged with logger.info and remembered as { projectId, projectName: event.name }; publishing stores the total; file-uploaded reports DeepnoteCloud.publishProgress(uploaded, total) with increment: 100 / total; operation-failed goes to logger.warn as <operation> failed for <path>: <message>; mirror-incomplete is remembered, and each failure goes to logger.warn; every other event goes to logger.debug.
        • When a project-created event was received, save the created project for the folder, whether publishApp resolved or rejected.
        • On a rejection:
          • PublishDivergedError with a projectId target: show window.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. undefined stops; the sync command has already told the user why, or the user cancelled. Otherwise, outcome is the entry of result.projects whose projectId is the target's. When (outcome?.filesDownloaded ?? 0) > 0, show DeepnoteCloud.publishSyncedChanges(workspace.asRelativePath(Uri.joinPath(Uri.file(error.syncRoot), projectFilesDir(outcome.path), PROJECT_STATIC_ROOT))) as an information message. In every other case, show window.showWarningMessage(DeepnoteCloud.publishSyncMissedAppFiles(projectName)), whatever result.success says about other projects, where projectName is the target's projectName with every ]( replaced by ] (, so a name from Deepnote cannot form a command link.
            • "Force" calls publish again with force: true, which takes the lock again.
            • Dismissing stops.
          • Any other error goes to void showCloudError(DeepnoteCloud.publishFailed, error, folder).
        • On a result:
          • when errors.length > 0, show window.showWarningMessage(DeepnoteCloud.publishPartiallyFailed(errors.length), DeepnoteCloud.showLog), not awaited; the button runs Commands.ViewJupyterOutput;
          • otherwise, when appUrl is set, call void showPublishedAppUrl(DeepnoteCloud.publishSucceeded(appUrl), appUrl);
          • when a mirror-incomplete event was received, also show window.showWarningMessage(DeepnoteCloud.publishMirrorIncomplete(<the event's syncRoot>)).
    • Unit tests, in publishAppCommandHandler.node.unit.test.ts:
      • Fixtures: a subclass overrides listProjects and runPublishApp with sinon stubs; runPublishApp resolves a result with created: false and no errors by default. IDeepnoteApiTokenService is a ts-mockito mock resolving 'tok'. The lock is a real DeepnoteCloudOperationLock, and workspaceState a MockMemento. window, env, commands and workspace are stubbed through mockedVSCodeNamespaces; deepnote settings return their defaults, so the base URL is https://api.deepnote.com. The command is invoked through the handler captured from commands.registerCommand. Folders live in a temporary directory <tmp>, and setup asserts that no .deepnote-sync.json sits above it. The sync-root fixture is <tmp>/root/.deepnote-sync.json containing {"version":1,"projects":{"p1":{"dir":"Team/Sales","notebooks":[],"contentHash":"x"}}}. listProjects resolves Ops (p-ops, data_app, folder: null), Notebooks (p-notebooks, standard), Untyped (p-untyped, no projectType), Sales (p-sales, data_app, folder path Team / 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>/app is published. A dismissed dialog requests no token and publishes nothing.
        • getOrPromptToken resolving undefined: no quick pick, and runPublishApp is not called.
        • First publish of <tmp>/app with "Sales" picked: listProjects receives https://api.deepnote.com; the items are New Data App… (description Create a data app project and publish to it), Alpha, Ops, and Sales with description Team / Q3; the non-function options of runPublishApp deep-equal { dir: <tmp>/app, projectId: 'p-sales', baseUrl: 'https://api.deepnote.com', token: 'tok', force: false }; workspace state holds { projectId: 'p-sales', projectName: 'Sales' } under Uri.file('<tmp>/app').toString().
        • A dismissed project pick: nothing is saved, and runPublishApp is not called.
        • listProjects rejecting with new Error('boom'): the error message Could not load Deepnote projects: boom, and runPublishApp is not called.
        • Folder <tmp>/root/Team/Sales/app with "Publish": the modal's arguments are ['Publish app to Sales?', { modal: true }, 'Publish'], listProjects is not called, runPublishApp receives projectId p1, and nothing is saved. Dismissing that modal publishes nothing.
        • Folder <tmp>/root/Team/SalesArchive/app lists the projects; so does <tmp>/root/Team/Sales/app when the manifest contains {.
        • Saved p-sales and "Publish": the modal's arguments are ['Publish app to Sales?', { modal: true }, 'Publish', 'Change Settings…'], no quick pick opens, and runPublishApp is called once with p-sales. A dismissed modal opens no quick pick and publishes nothing.
        • Saved p-sales and "Change Settings…" with "Ops" picked: the items are Sales with detail Current choice, then New Data App…, Alpha and Ops; { projectId: 'p-ops', projectName: 'Ops' } is saved and published to.
      • suite('creating a data app'), picking New Data App… and entering Revenue review :
        • runPublishApp emitting { 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 message Publishing the app failed: Could not load project p-new: boom, and p-new is saved.
        • The input box's validateInput returns Enter a name for the data app. for ' ' and undefined for 'Revenue review'.
        • A dismissed name prompt: nothing is saved, and runPublishApp is not called.
      • suite('one operation at a time'):
        • <tmp>/app while the test holds the lock: it still publishes.
        • <tmp>/root/Team/Sales/app while the test holds the lock: the information messages are Publish app to Sales? and A sync or app publish with Deepnote Cloud is already in progress., and runPublishApp is not called.
        • While runPublishApp runs for <tmp>/root/Team/Sales/app, the test cannot acquire the lock; after the publish settles, it can.
      • suite('results'):
        • Events publishing with fileCount: 2 and two file-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 is App published: https://deepnote.com/app/x with "Open" and "Copy". "Copy" writes that URL to the clipboard and sets the status-bar message App URL copied for 3000 ms; "Open" calls env.openExternal with 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" runs Commands.ViewJupyterOutput.
        • A mirror-incomplete event with syncRoot: '/sync' and an appUrl: the information message App published: …, then the warning The app was published, but the synced copy in /sync could not be fully updated. Sync again to refresh it.
        • runPublishApp rejecting with new PublishError('invalid-input', 'Refusing to publish ".env"'): the error message Publishing the app failed: Refusing to publish ".env", and no information message.
      • suite('app files changed in Deepnote'), for folder <tmp>/root/Team/Sales/app confirmed with "Publish", with the first runPublishApp call rejecting with new 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' force values are [false, true].
        • With 10 paths, the message lists all of them; with 12, it lists the first 10, then …and 2 more.
        • Dismissed: runPublishApp is called once, and the sync command is not run.
        • "Sync First" (sync results use root: '<tmp>/root', dryRun: false and untrackedFiles: []):
          • { success: true, projects: [{ projectId: 'p1', name: 'Sales', path: 'Team/Sales', action: 'unchanged', filesDownloaded: 1 }] }: the sync command receives { root: <tmp>/root, allFiles: true }, runPublishApp is called once, and the information message names root/Team/Sales/.files/_deepnote_static.
          • success: false with p1 { action: 'error', detail: 'export failed' } and { projectId: 'p2', name: 'Ops', path: 'Ops', action: 'pulled', filesDownloaded: 2 }: the warning is publishSyncMissedAppFiles('Sales'), and publishSyncedChanges is not shown.
          • success: true with p1 { action: 'pushed', filesUploaded: 0 }: the warning is publishSyncMissedAppFiles('Sales').
          • Folder <tmp>/root/site with the data app X [Show Log](command:evil) (p-x) picked, and p-x pushed: the warning names X [Show Log] (command:evil).
          • The sync command resolving undefined: the only warning is the divergence modal, no information message follows the confirmation, and runPublishApp is called once.
  6. 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() and API_TOKEN from syncWithDeepnoteCloud.e2e.test.ts to test/e2e/helpers/apiToken.ts, export them from test/e2e/helpers/index.ts, and import them in the sync suite.
    • publishApp.e2e.test.ts, in describe('Deepnote Cloud — publishing an app to the mock Deepnote Cloud'):
      • Setup:
        • startMockDeepnoteCloud() with two projects: <rootDir>/4f0c2a8e-6b1d-4c3e-9a7f-2d5e8b1c0a93/ holding cloud-sync.deepnote and an empty .data-app marker (data app "E2E Cloud Sync"), and <rootDir>/8b3e1f6a-2c4d-4e5f-9a7b-1c3d5e7f9a2b/ holding cloud-sync-analytics-costs.deepnote with no marker (standard project "Analytics").
        • A temp workspace with .vscode/settings.json setting deepnote.apiUrl to the mock URL and explorer.compactFolders to false, so Sales/app is its own row; site/index.html (<h1>site v1</h1>); newapp/index.html (<h1>new app</h1>); and a sync root synced/.deepnote-sync.json tracking the data app project with dir Sales, notebooks: [] and contentHash: 'x', plus synced/Sales/app/index.html (<h1>synced app</h1>).
        • It opens the folder with openFolderViaDialog and saves the token with setApiToken().
      • 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 but App 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 title New Data App….
      • The setup publishes, in order:
        1. site: pick "E2E Cloud Sync" in Choose the data app to publish to.
        2. site again, after rewriting site/index.html to <h1>site v2</h1>: confirm "Publish" in the modal containing Publish site to E2E Cloud Sync?.
        3. newapp: pick "New Data App…" and enter E2E New App. The new project's ID is read from the /apps/<id>/ end of the published URL.
        4. synced/Sales/app: confirm "Publish" in the modal containing Publish app to Sales?, then wait until synced/Sales/.files shows in the explorer.
        5. synced/Sales/app again, after rewriting the data app's .files/_deepnote_static/index.html in the mock to <h1>edited in Deepnote</h1>: confirm "Publish", then "Force" in the modal containing changed in Deepnote since the last sync.
      • Tests:
        • The first pick listed exactly New Data App… and E2E Cloud Sync; the first publish reported App published: <mock url>/apps/<data app id>/; the mock's .files/_deepnote_static/index.html was <h1>site v1</h1>; and GET /v2/projects/<data app id> reported dataApp { 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> reports name: 'E2E New App' and dataApp { published: true, url: '<mock url>/apps/<id>/' }; and its .files/_deepnote_static/index.html is <h1>new app</h1>.
        • Both publishes of synced/Sales/app reported the data app's URL, and the mock's file ended as <h1>synced app</h1>.
  7. 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" }, after deepnote.syncNewFolderWithDeepnoteCloud, and in package.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 no commandPalette entry; its enablement hides 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 the Deepnote: 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_TOKEN from a .env file in a synced folder above the app folder, or else the token saved with Deepnote: Set API Token; a folder that contains a .env or .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_static folder 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.

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 build writes dist/.

    1. Run npm run build in a terminal, then publish dist/ with "New Data App…" and a new name.
    2. Change a source file, run npm run build again, and publish dist/ again with "Publish" in the confirmation.
    3. Publish a plain folder holding only index.html to 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.

  • Refused publishes. Precondition: the same workspace and token.

    1. Turn "Allow static file sharing in projects" off, and publish a folder to a data app.
    2. 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 failed publish of project settings with Deepnote's 403 message. The second shows Publishing 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.

    1. Edit _deepnote_static/index.html in Deepnote.
    2. Publish locally and choose "Sync First".

    Expected: the sync summary appears, and the message names the project's .files/_deepnote_static folder, where the edited file contains the Deepnote edit. Publishing again, without merging, uploads the local folder without a divergence prompt.

  • 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: publish dist/ 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/deepnote repository. Action: open a pull request there that documents "Deepnote: Publish App…" in docs/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 unpublish does; 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, the DeepnoteCloud.publish string and the IDeepnoteCloudOperationLock from 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

  1. linear-code commented on Nov 7, 2026

    @linear-code
  2. changed the title [-]Publish a folder as a Deepnote app[/-] [+]Publish a folder as a Deepnote data app[/+] on Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions