diff --git a/templates/content/.agents/skills/document-editing/SKILL.md b/templates/content/.agents/skills/document-editing/SKILL.md index 32673e22b4..8c27aee6e9 100644 --- a/templates/content/.agents/skills/document-editing/SKILL.md +++ b/templates/content/.agents/skills/document-editing/SKILL.md @@ -82,12 +82,20 @@ pnpm action update-document --id abc123 --description "Stable guidance for what ### delete-document -Delete a document and all its children recursively. +Move a document and all its children to Trash. IDs, bodies, hierarchy, and +database membership remain intact so the subtree can be restored. ```bash pnpm action delete-document --id abc123 ``` +Restore the root subtree, or permanently delete it only after it is in Trash: + +```bash +pnpm action restore-document --id abc123 +pnpm action permanently-delete-document --id abc123 +``` + ## Comments Comments are Notion/Google-Docs-style **inline comments**. Selecting text and commenting leaves the passage **highlighted inline** via a ProseMirror decoration overlay — nothing is written into the markdown body, so the document round-trips unchanged. Each thread stores the quoted text plus surrounding context (`anchorPrefix`/`anchorSuffix`) and an approximate `anchorStartOffset`, so the highlight follows the text as the document is edited, disambiguates repeated text, and degrades gracefully (the thread stays in the sidebar) when its text is deleted. diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 3780159ae9..61cad86a2d 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -146,6 +146,9 @@ cd templates/content && pnpm action [args] | `get-content-database` | `--databaseId ` or `--documentId ` | Get a database with its description, property/option schema guidance, item pages, and computed ancestry context | | `list-trashed-content-databases` | | List soft-deleted databases visible in the sidebar Trash surface | | `restore-content-database` | `--databaseId ` | Restore a soft-deleted database from the sidebar Trash surface | +| `list-trashed-documents` | | List access-filtered page roots visible in the sidebar Trash surface | +| `restore-document` | `--id ` | Restore a page and the subtree moved to Trash with it | +| `permanently-delete-document` | `--id ` | Permanently destroy a document subtree already in Trash | | `get-content-database-source` | `--databaseId ` or `--documentId ` | Inspect local/no-source or source-backed status, mappings, row identity, freshness, and change sets | | `attach-content-database-source` | `--databaseId ` or `--documentId [--sourceType mock-local\|builder-cms\|local-table\|notion-database] [--sourceName] [--sourceTable] [--relationshipMode items\|details] [--join ]` | Attach a source binding; use `items` to add more Builder rows and `details` to match a read-only local/Notion source onto existing rows | | `list-notion-database-sources` | `[--query ] [--limit <1-100>]` | List Notion data sources visible through the current user's OAuth connection; returns IDs/names only, never tokens | @@ -175,7 +178,7 @@ cd templates/content && pnpm action [args] | `reorder-document-property` | `--documentId --propertyId --targetPropertyId [--position before\|after]` | Reorder a property definition within its database (used to reorder Blocks fields on the page) | | `set-document-discoverability` | `--id --hideFromSearch true\|false [--includeChildren true\|false]` | Hide/show an org-accessible document in Organization/search while keeping link access | | `move-document` | `--id [--parentId] [--position]` | Move or reorder a document in the page tree | -| `delete-document` | `--id ` | Delete with recursive children | +| `delete-document` | `--id ` | Move a document and its recursive children to Trash | Database views follow Notion-style tab labels. When creating or duplicating views in `viewConfig`, use unique default names (`Table 2`, `SEO copy 2`, etc.) diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index 2535f21093..5c906e71a5 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -806,7 +806,11 @@ async function provisionOwnedContentSpace( export async function provisionSourceBackedContentSpace( db: Db, userEmail: string, - input: { connectionId: string; name: string }, + input: { + connectionId: string; + name: string; + propertyValues?: Record; + }, ) { const connectionId = input.connectionId.trim(); if (!connectionId) throw new Error("Local folder connection ID is required"); @@ -814,6 +818,7 @@ export async function provisionSourceBackedContentSpace( spaceId: sourceBackedContentSpaceId(userEmail, connectionId), name: input.name.trim() || "Local folder", kind: "source_backed", + propertyValues: input.propertyValues, }); } diff --git a/templates/content/actions/_database-row-batch.ts b/templates/content/actions/_database-row-batch.ts index aa48562f37..edd21b7392 100644 --- a/templates/content/actions/_database-row-batch.ts +++ b/templates/content/actions/_database-row-batch.ts @@ -151,6 +151,7 @@ export async function resolveDatabaseRowsForBatch( eq(schema.contentDatabaseItems.databaseId, database.id), rowPredicates.length === 1 ? rowPredicates[0] : or(...rowPredicates), isNull(schema.contentDatabases.deletedAt), + isNull(schema.documents.trashedAt), ), ) .orderBy(asc(schema.contentDatabaseItems.position)); @@ -185,9 +186,21 @@ export async function renumberDatabaseRows( now: string, ) { const rows = await db - .select() + .select({ + id: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, database.id)) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, database.id), + isNull(schema.documents.trashedAt), + ), + ) .orderBy(asc(schema.contentDatabaseItems.position)); if (rows.length === 0) return; diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index e4b2ed0904..6ec1470fdf 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -22,7 +22,10 @@ import type { ContentDatabaseResponse, } from "../shared/api.js"; import { favoriteDocumentIds } from "./_content-favorites.js"; -import { listContentOrganizationMemberships } from "./_content-space-access.js"; +import { + listContentOrganizationMemberships, + normalizeContentSpaceEmail, +} from "./_content-space-access.js"; import { getAllContentDatabaseSourceSnapshots } from "./_database-source-utils.js"; import { applyFederatedOverlayValues, @@ -81,6 +84,8 @@ export const contentDatabaseListDocumentSelection = { sourcePath: schema.documents.sourcePath, sourceRootPath: schema.documents.sourceRootPath, sourceUpdatedAt: schema.documents.sourceUpdatedAt, + trashedAt: schema.documents.trashedAt, + trashRootId: schema.documents.trashRootId, visibility: schema.documents.visibility, ownerEmail: schema.documents.ownerEmail, orgId: schema.documents.orgId, @@ -300,9 +305,14 @@ export async function getContentDatabaseResponse( const { limit, offset } = normalizeContentDatabasePageOptions(options); const userEmail = getRequestUserEmail(); + const normalizedUserEmail = userEmail + ? normalizeContentSpaceEmail(userEmail) + : null; const activeOrgId = getRequestOrgId(); const authorizedOrgIds = - database.systemRole === "favorites" && userEmail + (database.systemRole === "favorites" || + database.systemRole === "workspaces") && + userEmail ? [ ...new Set([ ...(await listContentOrganizationMemberships(userEmail)).map( @@ -312,6 +322,45 @@ export async function getContentDatabaseResponse( ]), ] : []; + const workspacesVisibleDocumentIds = + database.systemRole === "workspaces" && normalizedUserEmail + ? ( + await db + .select({ + documentId: schema.contentSpaceCatalogItems.documentId, + ownerEmail: schema.contentSpaces.ownerEmail, + orgId: schema.contentSpaces.orgId, + }) + .from(schema.contentSpaceCatalogItems) + .innerJoin( + schema.contentSpaces, + eq( + schema.contentSpaces.id, + schema.contentSpaceCatalogItems.spaceId, + ), + ) + .where( + and( + eq( + schema.contentSpaceCatalogItems.catalogDatabaseId, + databaseId, + ), + eq( + schema.contentSpaceCatalogItems.ownerEmail, + normalizedUserEmail, + ), + isNull(schema.contentSpaces.archivedAt), + ), + ) + ) + .filter( + (row) => + normalizeContentSpaceEmail(row.ownerEmail) === + normalizedUserEmail || + (!!row.orgId && authorizedOrgIds.includes(row.orgId)), + ) + .map((row) => row.documentId) + : null; const favoritesVisibleDocumentIds = database.systemRole === "favorites" && userEmail ? ( @@ -331,6 +380,7 @@ export async function getContentDatabaseResponse( }), ), ), + isNull(schema.documents.trashedAt), documentDiscoveryFilter({ userEmail, orgIds: authorizedOrgIds, @@ -351,6 +401,11 @@ export async function getContentDatabaseResponse( : undefined; const visibleItemFilter = and( eq(schema.contentDatabaseItems.databaseId, databaseId), + sql`exists ( + select 1 from ${schema.documents} + where ${schema.documents.id} = ${schema.contentDatabaseItems.documentId} + and ${schema.documents.trashedAt} is null + )`, organizationFilesItemFilter, favoritesVisibleDocumentIds ? favoritesVisibleDocumentIds.length > 0 @@ -360,6 +415,14 @@ export async function getContentDatabaseResponse( ) : sql`1 = 0` : undefined, + workspacesVisibleDocumentIds + ? workspacesVisibleDocumentIds.length > 0 + ? inArray( + schema.contentDatabaseItems.documentId, + workspacesVisibleDocumentIds, + ) + : sql`1 = 0` + : undefined, ); const [itemCount] = await db .select({ count: sql`COUNT(*)` }) @@ -388,23 +451,28 @@ export async function getContentDatabaseResponse( schema.documents.id, items.map((item) => item.documentId), ), + isNull(schema.documents.trashedAt), database.systemRole === "favorites" ? favoritesVisibleDocumentIds?.length ? inArray(schema.documents.id, favoritesVisibleDocumentIds) : sql`1 = 0` - : database.systemRole === "files" && database.orgId - ? and( - eq(schema.documents.orgId, database.orgId), - or( - eq(schema.documents.visibility, "org"), - eq(schema.documents.visibility, "public"), - ), - or( - eq(schema.documents.hideFromSearch, 0), - isNull(schema.documents.hideFromSearch), - ), - ) - : eq(schema.documents.ownerEmail, database.ownerEmail), + : database.systemRole === "workspaces" + ? workspacesVisibleDocumentIds?.length + ? inArray(schema.documents.id, workspacesVisibleDocumentIds) + : sql`1 = 0` + : database.systemRole === "files" && database.orgId + ? and( + eq(schema.documents.orgId, database.orgId), + or( + eq(schema.documents.visibility, "org"), + eq(schema.documents.visibility, "public"), + ), + or( + eq(schema.documents.hideFromSearch, 0), + isNull(schema.documents.hideFromSearch), + ), + ) + : eq(schema.documents.ownerEmail, database.ownerEmail), ), ) : []; diff --git a/templates/content/actions/add-database-item.ts b/templates/content/actions/add-database-item.ts index 12d2f6bfe2..34af4d132d 100644 --- a/templates/content/actions/add-database-item.ts +++ b/templates/content/actions/add-database-item.ts @@ -41,6 +41,9 @@ export default defineAction({ ), ); if (!database) throw new Error(`Database "${databaseId}" not found`); + if (database.systemRole === "workspaces") { + throw new Error("Use create-content-space to add a workspace"); + } const access = await assertAccess( "document", diff --git a/templates/content/actions/connect-local-folder-source.ts b/templates/content/actions/connect-local-folder-source.ts index ce5b262adb..f48afa84e2 100644 --- a/templates/content/actions/connect-local-folder-source.ts +++ b/templates/content/actions/connect-local-folder-source.ts @@ -51,6 +51,7 @@ export default defineAction({ .optional() .default(false) .describe("Create a separate private Content space for this folder"), + propertyValues: z.record(z.string(), z.unknown()).optional(), truthPolicy: truthPolicySchema.describe( "Whether Content, the folder, or reviewed conflict resolution is authoritative", ), @@ -130,7 +131,7 @@ export default defineAction({ const provisioned = await provisionSourceBackedContentSpace( db, userEmail, - { connectionId, name: label }, + { connectionId, name: label, propertyValues: args.propertyValues }, ); targetSpaceId = provisioned.spaceId; targetDatabaseId = provisioned.filesDatabaseId; diff --git a/templates/content/actions/content-database-lifecycle.db.test.ts b/templates/content/actions/content-database-lifecycle.db.test.ts index 0e915436f6..df104c4448 100644 --- a/templates/content/actions/content-database-lifecycle.db.test.ts +++ b/templates/content/actions/content-database-lifecycle.db.test.ts @@ -24,8 +24,13 @@ let getContentDatabaseAction: typeof import("./get-content-database.js").default let listDocumentsAction: typeof import("./list-documents.js").default; let listTrashedContentDatabasesAction: typeof import("./list-trashed-content-databases.js").default; let getDocumentAction: typeof import("./get-document.js").default; +let pullDocumentAction: typeof import("./pull-document.js").default; let listDocumentPropertiesAction: typeof import("./list-document-properties.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; +let deleteDocumentAction: typeof import("./delete-document.js").default; +let restoreDocumentAction: typeof import("./restore-document.js").default; +let permanentlyDeleteDocumentAction: typeof import("./permanently-delete-document.js").default; +let listTrashedDocumentsAction: typeof import("./list-trashed-documents.js").default; const OWNER = "owner@example.com"; const COLLABORATOR = "collaborator@example.com"; @@ -48,9 +53,17 @@ beforeAll(async () => { await import("./list-trashed-content-databases.js") ).default; getDocumentAction = (await import("./get-document.js")).default; + pullDocumentAction = (await import("./pull-document.js")).default; listDocumentPropertiesAction = (await import("./list-document-properties.js")) .default; addDatabaseItemAction = (await import("./add-database-item.js")).default; + deleteDocumentAction = (await import("./delete-document.js")).default; + restoreDocumentAction = (await import("./restore-document.js")).default; + permanentlyDeleteDocumentAction = ( + await import("./permanently-delete-document.js") + ).default; + listTrashedDocumentsAction = (await import("./list-trashed-documents.js")) + .default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); }, 60000); @@ -164,6 +177,201 @@ async function databaseRow(databaseId: string) { return database; } +describe("document trash lifecycle", () => { + it("round-trips a page subtree without changing ids, bodies, or hierarchy", async () => { + const rootId = await createDocument({ + title: "Trash root", + content: "Root body", + }); + const childId = await createDocument({ + parentId: rootId, + title: "Trash child", + content: "Child body", + }); + + const deleted = await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + expect(deleted.deleted).toBe(2); + expect(await documentRow(rootId)).toMatchObject({ + content: "Root body", + trashedAt: expect.any(String), + trashRootId: rootId, + }); + expect(await documentRow(childId)).toMatchObject({ + parentId: rootId, + content: "Child body", + trashedAt: expect.any(String), + trashRootId: rootId, + }); + + const active = await runWithRequestContext({ userEmail: OWNER }, () => + listDocumentsAction.run({}), + ); + expect(active.documents.map((document) => document.id)).not.toContain( + rootId, + ); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + getDocumentAction.run({ id: rootId }), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + + const trash = await runWithRequestContext({ userEmail: OWNER }, () => + listTrashedDocumentsAction.run({}), + ); + expect(trash.documents).toContainEqual( + expect.objectContaining({ documentId: rootId, title: "Trash root" }), + ); + expect( + trash.documents.map((document) => document.documentId), + ).not.toContain(childId); + + const restored = await runWithRequestContext({ userEmail: OWNER }, () => + restoreDocumentAction.run({ id: rootId }), + ); + expect(restored.restored).toBe(2); + expect(await documentRow(rootId)).toMatchObject({ + content: "Root body", + trashedAt: null, + trashRootId: null, + }); + expect(await documentRow(childId)).toMatchObject({ + parentId: rootId, + content: "Child body", + trashedAt: null, + trashRootId: null, + }); + }); + + it("does not restore a child that was trashed separately before its parent", async () => { + const rootId = await createDocument({ title: "Later parent" }); + const childId = await createDocument({ + parentId: rootId, + title: "Earlier child", + }); + + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: childId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + restoreDocumentAction.run({ id: rootId }), + ); + + expect(await documentRow(rootId)).toMatchObject({ trashedAt: null }); + expect(await documentRow(childId)).toMatchObject({ + trashedAt: expect.any(String), + trashRootId: childId, + }); + }); + + it("does not restore a database that was already in Trash before its parent", async () => { + const rootId = await createDocument({ + title: "Parent of trashed database", + }); + const databaseDeletedAt = "2026-07-19T12:00:00.000Z"; + const { databaseId, databaseDocumentId } = await createDatabase({ + backingParentId: rootId, + deletedAt: databaseDeletedAt, + }); + + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + restoreDocumentAction.run({ id: rootId }), + ); + + expect(await databaseRow(databaseId)).toMatchObject({ + deletedAt: databaseDeletedAt, + }); + expect(await documentRow(databaseDocumentId)).toMatchObject({ + trashedAt: null, + trashRootId: null, + }); + }); + + it("requires Trash before permanent deletion", async () => { + const documentId = await createDocument({ title: "Purge me" }); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + permanentlyDeleteDocumentAction.run({ id: documentId }), + ), + ).rejects.toThrow("must be in Trash"); + + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: documentId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + permanentlyDeleteDocumentAction.run({ id: documentId }), + ); + expect(await documentRow(documentId)).toBeUndefined(); + }); + + it("permanently deletes only a selected Trash root", async () => { + const rootId = await createDocument({ title: "Trash root" }); + const childId = await createDocument({ + parentId: rootId, + title: "Trash child", + }); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + permanentlyDeleteDocumentAction.run({ id: childId }), + ), + ).rejects.toThrow("Trash root"); + expect(await documentRow(rootId)).toBeDefined(); + expect(await documentRow(childId)).toBeDefined(); + }); + + it("preserves an independently trashed descendant when deleting its parent root", async () => { + const rootId = await createDocument({ title: "Later root" }); + const childId = await createDocument({ + parentId: rootId, + title: "Earlier root", + }); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: childId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + permanentlyDeleteDocumentAction.run({ id: rootId }), + ); + + expect(await documentRow(rootId)).toBeUndefined(); + expect(await documentRow(childId)).toMatchObject({ + parentId: null, + trashRootId: childId, + trashedAt: expect.any(String), + }); + }); + + it("does not expose another owner's trashed page title", async () => { + const foreignId = await createDocument({ + title: "Sensitive foreign title", + ownerEmail: COLLABORATOR, + }); + await runWithRequestContext({ userEmail: COLLABORATOR }, () => + deleteDocumentAction.run({ id: foreignId }), + ); + + const trash = await runWithRequestContext({ userEmail: OWNER }, () => + listTrashedDocumentsAction.run({}), + ); + expect( + trash.documents.map((document) => document.documentId), + ).not.toContain(foreignId); + }); +}); + describe("inline database lifecycle reconcile", () => { it("does not let a stale empty preview save replace a newer hydrated body", async () => { const documentId = await createDocument({ @@ -480,6 +688,11 @@ describe("content database soft-delete actions and reads", () => { getDocumentAction.run({ id: rowDocumentId }), ), ).rejects.toThrow(`Document "${rowDocumentId}" not found`); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + pullDocumentAction.run({ id: rowDocumentId, format: "markdown" }), + ), + ).rejects.toThrow(`Document "${rowDocumentId}" not found`); await expect( runWithRequestContext({ userEmail: OWNER }, () => listDocumentPropertiesAction.run({ documentId: rowDocumentId }), @@ -487,6 +700,66 @@ describe("content database soft-delete actions and reads", () => { ).rejects.toThrow(`Document "${rowDocumentId}" not found`); }); + it("rejects restoring a database whose page belongs to another Trash root", async () => { + const rootId = await createDocument({ title: "Parent Trash root" }); + const { databaseId, databaseDocumentId } = await createDatabase({ + backingParentId: rootId, + }); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + restoreContentDatabaseAction.run({ databaseId }), + ), + ).rejects.toThrow("Restore the parent Trash item instead"); + expect(await databaseRow(databaseId)).toMatchObject({ + deletedAt: expect.any(String), + }); + expect(await documentRow(databaseDocumentId)).toMatchObject({ + trashRootId: rootId, + trashedAt: expect.any(String), + }); + }); + + it("requires backing-page admin access before deleting an inline database", async () => { + const hostDocumentId = await createDocument({ title: "Shared host" }); + const { databaseId, databaseDocumentId } = await createDatabase({ + hostDocumentId, + ownerBlockId: nextId("inline_database"), + }); + const db = getDb(); + await db.insert(schema.documentShares).values({ + id: nextId("share"), + resourceId: hostDocumentId, + principalType: "user", + principalId: COLLABORATOR, + role: "editor", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + await db.insert(schema.documentShares).values({ + id: nextId("share"), + resourceId: databaseDocumentId, + principalType: "user", + principalId: COLLABORATOR, + role: "editor", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + + await expect( + runWithRequestContext({ userEmail: COLLABORATOR }, () => + deleteContentDatabaseAction.run({ databaseId }), + ), + ).rejects.toThrow(`Requires admin role on document ${databaseDocumentId}`); + expect(await documentRow(databaseDocumentId)).toMatchObject({ + trashedAt: null, + }); + expect(await databaseRow(databaseId)).toMatchObject({ deletedAt: null }); + }); + it("blocks row mutations for soft-deleted databases", async () => { const { databaseId } = await createDatabase({ deletedAt: new Date().toISOString(), @@ -548,7 +821,7 @@ describe("content database soft-delete actions and reads", () => { documentId: ownedDeleted.databaseDocumentId, ownerDocumentId: null, deletedAt, - canPermanentlyDelete: true, + canPermanentlyDelete: false, }, ]), ); diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 5f268a6c2f..fc7693b631 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -32,6 +32,7 @@ let setDocumentPropertyAction: typeof import("./set-document-property.js").defau let deleteContentDatabaseAction: typeof import("./delete-content-database.js").default; let deleteDocumentAction: typeof import("./delete-document.js").default; let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; +let addDatabaseItemAction: typeof import("./add-database-item.js").default; let duplicateDatabaseItemAction: typeof import("./duplicate-database-item.js").default; let duplicateDatabaseItemsAction: typeof import("./duplicate-database-items.js").default; @@ -71,6 +72,7 @@ beforeAll(async () => { deleteDocumentAction = (await import("./delete-document.js")).default; deleteDatabaseItemsAction = (await import("./delete-database-items.js")) .default; + addDatabaseItemAction = (await import("./add-database-item.js")).default; duplicateDatabaseItemAction = (await import("./duplicate-database-item.js")) .default; duplicateDatabaseItemsAction = (await import("./duplicate-database-items.js")) @@ -423,6 +425,12 @@ describe("Content space provisioning", () => { await expect( deleteDocumentAction.run({ id: files.documentId }), ).rejects.toThrow("System Content database documents cannot be deleted"); + await expect( + addDatabaseItemAction.run({ + databaseId: workspaces.id, + title: "Not a workspace", + }), + ).rejects.toThrow("Use create-content-space to add a workspace"); }); }); @@ -740,6 +748,21 @@ describe("Content space provisioning", () => { expect.objectContaining({ id: spaceId }), ]), }); + const [workspacesDatabase] = await getDb() + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.ownerEmail, MEMBER), + eq(schema.contentDatabases.systemRole, "workspaces"), + ), + ); + const catalog = await getContentDatabaseAction.run({ + databaseId: workspacesDatabase!.id, + }); + expect(catalog.items.map((item) => item.document.title)).not.toContain( + "Shared", + ); }); }); diff --git a/templates/content/actions/database-row-batch-actions.db.test.ts b/templates/content/actions/database-row-batch-actions.db.test.ts index eb3af80003..ad04b5e2a2 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { runWithRequestContext } from "@agent-native/core/server"; -import { asc, eq, inArray } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; const TEST_DB_PATH = join( @@ -15,8 +15,10 @@ type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; let schema: Schema; let duplicateDatabaseItemsAction: typeof import("./duplicate-database-items.js").default; +let duplicateDatabaseItemAction: typeof import("./duplicate-database-item.js").default; let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; +let restoreDocumentAction: typeof import("./restore-document.js").default; let spaceId: string; const OWNER = "owner@example.com"; @@ -29,9 +31,12 @@ beforeAll(async () => { schema = dbModule.schema; duplicateDatabaseItemsAction = (await import("./duplicate-database-items.js")) .default; + duplicateDatabaseItemAction = (await import("./duplicate-database-item.js")) + .default; deleteDatabaseItemsAction = (await import("./delete-database-items.js")) .default; addDatabaseItemAction = (await import("./add-database-item.js")).default; + restoreDocumentAction = (await import("./restore-document.js")).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); const { systemIdsForContentSpace } = await import("./_content-spaces.js"); @@ -157,7 +162,12 @@ async function orderedRows(databaseId: string) { schema.documents, eq(schema.documents.id, schema.contentDatabaseItems.documentId), ) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, databaseId), + isNull(schema.documents.trashedAt), + ), + ) .orderBy(asc(schema.contentDatabaseItems.position)); } @@ -355,7 +365,10 @@ describe("database row batch actions", () => { expect(remainingRows.map((row) => row.itemPosition)).toEqual([0, 1]); const deletedDocs = await db - .select({ id: schema.documents.id }) + .select({ + id: schema.documents.id, + trashedAt: schema.documents.trashedAt, + }) .from(schema.documents) .where( inArray(schema.documents.id, [ @@ -364,12 +377,44 @@ describe("database row batch actions", () => { childDocumentId, ]), ); - expect(deletedDocs).toEqual([]); - const deletedValues = await db + expect(deletedDocs).toHaveLength(3); + expect(deletedDocs.every((document) => document.trashedAt)).toBe(true); + const preservedValues = await db .select() .from(schema.documentPropertyValues) .where(eq(schema.documentPropertyValues.documentId, rows[1].documentId)); - expect(deletedValues).toEqual([]); + expect(preservedValues).toHaveLength(1); + }); + + it("rejects duplicating trashed rows and restores unique ordering", async () => { + const { databaseId, rows } = await createDatabaseWithRows(2); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + duplicateDatabaseItemAction.run({ itemId: rows[0].itemId }), + ), + ).rejects.toThrow("Database row not found"); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + duplicateDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ), + ).rejects.toThrow("All requested rows must exist in the target database"); + + await runWithRequestContext({ userEmail: OWNER }, () => + restoreDocumentAction.run({ id: rows[0].documentId }), + ); + const restoredRows = await orderedRows(databaseId); + expect(restoredRows.map((row) => row.itemPosition)).toEqual([0, 1]); + expect(restoredRows.map((row) => row.documentPosition)).toEqual([0, 1]); }); it("rejects unauthorized delete batches before writing", async () => { diff --git a/templates/content/actions/delete-content-database.ts b/templates/content/actions/delete-content-database.ts index 08fe83c5e9..6312903b20 100644 --- a/templates/content/actions/delete-content-database.ts +++ b/templates/content/actions/delete-content-database.ts @@ -1,10 +1,11 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { eq } from "drizzle-orm"; +import { assertAccess } from "@agent-native/core/sharing"; import { z } from "zod"; -import { getDb, schema } from "../server/db/index.js"; +import { getDb } from "../server/db/index.js"; import { assertContentDatabaseLifecycleAccess } from "./_content-database-lifecycle.js"; +import { trashDocumentSubtree } from "./delete-document.js"; export default defineAction({ description: @@ -17,15 +18,17 @@ export default defineAction({ if (database.systemRole) { throw new Error("System Content databases cannot be deleted"); } + await assertAccess("document", database.documentId, "admin"); const db = getDb(); const deletedAt = database.deletedAt ?? new Date().toISOString(); - - if (!database.deletedAt) { - await db - .update(schema.contentDatabases) - .set({ deletedAt, updatedAt: deletedAt }) - .where(eq(schema.contentDatabases.id, databaseId)); - } + await db.transaction((tx) => + trashDocumentSubtree( + tx as unknown as ReturnType, + database.documentId, + database.ownerEmail, + deletedAt, + ), + ); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/delete-database-items.ts b/templates/content/actions/delete-database-items.ts index d1c9248af3..a059abdd63 100644 --- a/templates/content/actions/delete-database-items.ts +++ b/templates/content/actions/delete-database-items.ts @@ -11,7 +11,7 @@ import { resolveDatabaseRowsForBatch, } from "./_database-row-batch.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; -import { deleteDocumentRecursive } from "./delete-document.js"; +import { trashDocumentSubtree } from "./delete-document.js"; export default defineAction({ description: @@ -57,10 +57,11 @@ export default defineAction({ await db.transaction(async (tx) => { for (const row of rows) { - await deleteDocumentRecursive( + await trashDocumentSubtree( tx as unknown as ReturnType, row.document.id, row.document.ownerEmail, + now, ); } await renumberDatabaseRows( diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 727464bf19..4432b2c65e 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -1,12 +1,13 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { chunks } from "./_batch-utils.js"; import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; +import { renumberDatabaseRows } from "./_database-row-batch.js"; const DELETE_BATCH_SIZE = 90; @@ -155,6 +156,152 @@ async function collectDocumentSubtreeForDelete( }; } +export async function trashDocumentSubtree( + db: ReturnType, + id: string, + ownerEmail: string, + trashedAt = new Date().toISOString(), +): Promise { + const { documentIds } = await collectDocumentSubtreeForDelete( + db, + id, + ownerEmail, + ); + await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted"); + + const independentlyTrashedDatabaseDocumentIds = new Set(); + for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) { + for (const database of await db + .select({ documentId: schema.contentDatabases.documentId }) + .from(schema.contentDatabases) + .where( + and( + inArray(schema.contentDatabases.documentId, batch), + eq(schema.contentDatabases.ownerEmail, ownerEmail), + isNotNull(schema.contentDatabases.deletedAt), + ), + )) { + independentlyTrashedDatabaseDocumentIds.add(database.documentId); + } + } + + const activeDocumentIds: string[] = []; + for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) { + activeDocumentIds.push( + ...( + await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + inArray(schema.documents.id, batch), + eq(schema.documents.ownerEmail, ownerEmail), + isNull(schema.documents.trashedAt), + ), + ) + ) + .map((document) => document.id) + .filter( + (documentId) => + !independentlyTrashedDatabaseDocumentIds.has(documentId), + ), + ); + } + + for (const batch of chunks(activeDocumentIds, DELETE_BATCH_SIZE)) { + await db + .update(schema.documents) + .set({ trashedAt, trashRootId: id, updatedAt: trashedAt }) + .where( + and( + inArray(schema.documents.id, batch), + eq(schema.documents.ownerEmail, ownerEmail), + isNull(schema.documents.trashedAt), + ), + ); + await db + .update(schema.contentDatabases) + .set({ deletedAt: trashedAt, updatedAt: trashedAt }) + .where( + and( + inArray(schema.contentDatabases.documentId, batch), + eq(schema.contentDatabases.ownerEmail, ownerEmail), + isNull(schema.contentDatabases.deletedAt), + ), + ); + } + + return activeDocumentIds; +} + +export async function restoreDocumentSubtree( + db: ReturnType, + rootId: string, + ownerEmail: string, +): Promise { + const documentIds = ( + await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.trashRootId, rootId), + eq(schema.documents.ownerEmail, ownerEmail), + ), + ) + ).map((document) => document.id); + if (documentIds.length === 0) return []; + + const now = new Date().toISOString(); + for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) { + await db + .update(schema.documents) + .set({ trashedAt: null, trashRootId: null, updatedAt: now }) + .where( + and( + inArray(schema.documents.id, batch), + eq(schema.documents.ownerEmail, ownerEmail), + eq(schema.documents.trashRootId, rootId), + ), + ); + await db + .update(schema.contentDatabases) + .set({ deletedAt: null, updatedAt: now }) + .where( + and( + inArray(schema.contentDatabases.documentId, batch), + eq(schema.contentDatabases.ownerEmail, ownerEmail), + ), + ); + } + + const databaseIds = [ + ...new Set( + ( + await db + .select({ databaseId: schema.contentDatabaseItems.databaseId }) + .from(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.documentId, documentIds)) + ).map((item) => item.databaseId), + ), + ]; + for (const databaseId of databaseIds) { + const [database] = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, databaseId), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .limit(1); + if (database) await renumberDatabaseRows(db, database, now); + } + + return documentIds; +} + async function deleteWhereIn( items: T[], run: (batch: T[]) => Promise, @@ -171,6 +318,20 @@ export async function deleteDocumentRecursive( ): Promise { const { documentIds, ownedDatabaseIds } = await collectDocumentSubtreeForDelete(db, id, ownerEmail); + return deleteCollectedDocuments( + db, + documentIds, + ownedDatabaseIds, + ownerEmail, + ); +} + +async function deleteCollectedDocuments( + db: ReturnType, + documentIds: string[], + ownedDatabaseIds: string[], + ownerEmail: string, +): Promise { await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted"); const propertyDefinitionIds: string[] = []; @@ -353,8 +514,72 @@ export async function deleteDocumentRecursive( return documentIds; } +export async function deleteTrashedDocumentSubtree( + db: ReturnType, + id: string, + ownerEmail: string, +): Promise { + const [root] = await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.id, id), + eq(schema.documents.ownerEmail, ownerEmail), + eq(schema.documents.trashRootId, id), + isNotNull(schema.documents.trashedAt), + ), + ) + .limit(1); + if (!root) { + throw new Error( + "Document must be in Trash and be a Trash root before permanent deletion", + ); + } + + const documentIds = ( + await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, ownerEmail), + eq(schema.documents.trashRootId, id), + isNotNull(schema.documents.trashedAt), + ), + ) + ).map((document) => document.id); + const ownedDatabaseIds = await selectOwnedDatabaseIds( + db, + documentIds, + ownerEmail, + ).then((rows) => rows.map((database) => database.id)); + + await db + .update(schema.documents) + .set({ parentId: null, updatedAt: new Date().toISOString() }) + .where( + and( + eq(schema.documents.ownerEmail, ownerEmail), + inArray(schema.documents.parentId, documentIds), + or( + isNull(schema.documents.trashRootId), + ne(schema.documents.trashRootId, id), + ), + ), + ); + + return deleteCollectedDocuments( + db, + documentIds, + ownedDatabaseIds, + ownerEmail, + ); +} + export default defineAction({ - description: "Delete a document and all its children recursively.", + description: + "Move a document and all its children to Trash. Use permanently-delete-document to destroy an item already in Trash.", schema: z.object({ id: z.string().optional().describe("Document ID (required)"), databaseDocumentId: z @@ -408,10 +633,12 @@ export default defineAction({ if (systemDatabase?.systemRole) { throw new Error("System Content database documents cannot be deleted"); } - const deleted = await deleteDocumentRecursive( - db, - id, - existing.ownerEmail as string, + const deleted = await db.transaction((tx) => + trashDocumentSubtree( + tx as unknown as ReturnType, + id, + existing.ownerEmail as string, + ), ); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/duplicate-database-item.ts b/templates/content/actions/duplicate-database-item.ts index 47e145e503..5f28ecb52d 100644 --- a/templates/content/actions/duplicate-database-item.ts +++ b/templates/content/actions/duplicate-database-item.ts @@ -46,6 +46,7 @@ export default defineAction({ ? eq(schema.contentDatabaseItems.id, itemId) : eq(schema.contentDatabaseItems.documentId, documentId!), isNull(schema.contentDatabases.deletedAt), + isNull(schema.documents.trashedAt), ), ); diff --git a/templates/content/actions/get-document.ts b/templates/content/actions/get-document.ts index 77f6895c78..73ed27321b 100644 --- a/templates/content/actions/get-document.ts +++ b/templates/content/actions/get-document.ts @@ -70,7 +70,10 @@ export default defineAction({ statusCode: 404, }); } - if (await isSoftDeletedDatabaseDocument(args.id)) { + if ( + access.resource.trashedAt || + (await isSoftDeletedDatabaseDocument(args.id)) + ) { throw Object.assign(new Error(`Document "${args.id}" not found`), { statusCode: 404, }); diff --git a/templates/content/actions/list-content-databases.ts b/templates/content/actions/list-content-databases.ts index 3c10da97ba..c334928118 100644 --- a/templates/content/actions/list-content-databases.ts +++ b/templates/content/actions/list-content-databases.ts @@ -60,6 +60,7 @@ export default defineAction({ .where( and( accessFilter(schema.documents, schema.documentShares), + isNull(schema.documents.trashedAt), documentDiscoveryFilter(), isNull(schema.contentDatabases.deletedAt), excludedDatabaseIds.size === 1 diff --git a/templates/content/actions/list-documents.ts b/templates/content/actions/list-documents.ts index 91f651698f..05023b0c50 100644 --- a/templates/content/actions/list-documents.ts +++ b/templates/content/actions/list-documents.ts @@ -106,6 +106,7 @@ export default defineAction({ accessFilter(schema.documents, schema.documentShares, context), ), ), + isNull(schema.documents.trashedAt), documentDiscoveryFilter({ userEmail, orgIds: authorizedOrgIds, diff --git a/templates/content/actions/list-trashed-content-databases.ts b/templates/content/actions/list-trashed-content-databases.ts index 1e9c4d9666..4eca6b3f87 100644 --- a/templates/content/actions/list-trashed-content-databases.ts +++ b/templates/content/actions/list-trashed-content-databases.ts @@ -34,6 +34,7 @@ export default defineAction({ deletedAt: schema.contentDatabases.deletedAt, documentTitle: schema.documents.title, documentParentId: schema.documents.parentId, + documentTrashRootId: schema.documents.trashRootId, }) .from(schema.contentDatabases) .innerJoin( @@ -47,6 +48,10 @@ export default defineAction({ .where( and( isNotNull(schema.contentDatabases.deletedAt), + or( + isNull(schema.documents.trashRootId), + eq(schema.documents.trashRootId, schema.documents.id), + ), or( and( isBlockOwned, @@ -82,8 +87,9 @@ export default defineAction({ ownerDocumentId: row.ownerDocumentId, deletedAt: row.deletedAt!, canPermanentlyDelete: - row.ownerDocumentId === null || - row.documentParentId !== row.ownerDocumentId, + row.documentTrashRootId === row.documentId && + (row.ownerDocumentId === null || + row.documentParentId !== row.ownerDocumentId), })), }; }, diff --git a/templates/content/actions/list-trashed-documents.ts b/templates/content/actions/list-trashed-documents.ts new file mode 100644 index 0000000000..f4fd635769 --- /dev/null +++ b/templates/content/actions/list-trashed-documents.ts @@ -0,0 +1,50 @@ +import { defineAction } from "@agent-native/core"; +import { accessFilter } from "@agent-native/core/sharing"; +import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import type { ListTrashedDocumentsResponse } from "../shared/api.js"; + +export default defineAction({ + description: + "List trashed page roots the current user can manage. Titles are returned only after document access filtering.", + schema: z.object({}), + http: { method: "GET" }, + readOnly: true, + run: async (): Promise => { + const rows = await getDb() + .select({ + documentId: schema.documents.id, + title: schema.documents.title, + trashedAt: schema.documents.trashedAt, + }) + .from(schema.documents) + .leftJoin( + schema.contentDatabases, + eq(schema.contentDatabases.documentId, schema.documents.id), + ) + .where( + and( + isNotNull(schema.documents.trashedAt), + eq(schema.documents.trashRootId, schema.documents.id), + isNull(schema.contentDatabases.id), + accessFilter( + schema.documents, + schema.documentShares, + undefined, + "admin", + ), + ), + ) + .orderBy(desc(schema.documents.trashedAt)); + + return { + documents: rows.map((row) => ({ + documentId: row.documentId, + title: row.title.trim() || "Untitled", + trashedAt: row.trashedAt!, + })), + }; + }, +}); diff --git a/templates/content/actions/local-folder-source.db.test.ts b/templates/content/actions/local-folder-source.db.test.ts index c03aea261a..d704ebfe0c 100644 --- a/templates/content/actions/local-folder-source.db.test.ts +++ b/templates/content/actions/local-folder-source.db.test.ts @@ -21,6 +21,7 @@ let syncLocalFolder: typeof import("./sync-local-folder-source.js").default; let disconnectLocalFolder: typeof import("./disconnect-local-folder-source.js").default; let resolveLocalFolderConflict: typeof import("./resolve-local-folder-conflict.js").default; let syncManifestLocalFolder: typeof import("./sync-manifest-local-folder-source.js").default; +let provisionContentSpaces: typeof import("./_content-spaces.js").provisionContentSpaces; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -46,6 +47,8 @@ beforeAll(async () => { syncManifestLocalFolder = ( await import("./sync-manifest-local-folder-source.js") ).default; + provisionContentSpaces = (await import("./_content-spaces.js")) + .provisionContentSpaces; }, 60000); afterAll(() => { @@ -78,11 +81,29 @@ describe("local-folder Content source", () => { }); it("creates an opaque source-backed space and materializes files in canonical Files", async () => { + const provisioned = await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + const now = new Date().toISOString(); + await getDb().insert(schema.documentPropertyDefinitions).values({ + id: "source-workspace-focus-property", + ownerEmail: OWNER, + orgId: null, + databaseId: provisioned.catalogDatabaseId, + name: "Focus", + type: "text", + position: 0, + createdAt: now, + updatedAt: now, + }); const connection = await runWithRequestContext({ userEmail: OWNER }, () => connectLocalFolder.run({ connectionId: "desktop-folder-1", label: "Product docs", createSourceBackedSpace: true, + propertyValues: { + "source-workspace-focus-property": "Imported", + }, truthPolicy: "source_primary", }), ); @@ -97,6 +118,26 @@ describe("local-folder Content source", () => { .where(eq(schema.contentDatabaseSources.id, connection.sourceId)); expect(storedSource.sourceTable).toBe("desktop-folder-1"); expect(storedSource.metadataJson).not.toContain("/Users/"); + const [catalogMapping] = await getDb() + .select({ documentId: schema.contentSpaceCatalogItems.documentId }) + .from(schema.contentSpaceCatalogItems) + .where(eq(schema.contentSpaceCatalogItems.spaceId, connection.spaceId)); + const [workspacePropertyValue] = await getDb() + .select() + .from(schema.documentPropertyValues) + .where( + and( + eq( + schema.documentPropertyValues.documentId, + catalogMapping.documentId, + ), + eq( + schema.documentPropertyValues.propertyId, + "source-workspace-focus-property", + ), + ), + ); + expect(JSON.parse(workspacePropertyValue.valueJson)).toBe("Imported"); const first = await runWithRequestContext({ userEmail: OWNER }, () => syncLocalFolder.run({ diff --git a/templates/content/actions/permanently-delete-document.ts b/templates/content/actions/permanently-delete-document.ts new file mode 100644 index 0000000000..39c6d1745c --- /dev/null +++ b/templates/content/actions/permanently-delete-document.ts @@ -0,0 +1,28 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { assertAccess } from "@agent-native/core/sharing"; +import { z } from "zod"; + +import { getDb } from "../server/db/index.js"; +import { deleteTrashedDocumentSubtree } from "./delete-document.js"; + +export default defineAction({ + description: + "Permanently delete a document subtree that is already in Trash. This cannot be undone.", + schema: z.object({ + id: z.string().describe("Trashed root document ID"), + }), + run: async ({ id }) => { + const access = await assertAccess("document", id, "admin"); + const db = getDb(); + const deleted = await db.transaction((tx) => + deleteTrashedDocumentSubtree( + tx as unknown as ReturnType, + id, + access.resource.ownerEmail as string, + ), + ); + await writeAppState("refresh-signal", { ts: Date.now() }); + return { success: true, deleted: deleted.length }; + }, +}); diff --git a/templates/content/actions/pull-document.ts b/templates/content/actions/pull-document.ts index e0a48bd2bb..27c8fc34a3 100644 --- a/templates/content/actions/pull-document.ts +++ b/templates/content/actions/pull-document.ts @@ -4,6 +4,7 @@ import { resolveAccess } from "@agent-native/core/sharing"; import { z } from "zod"; import "../server/db/index.js"; +import { isSoftDeletedDatabaseDocument } from "./_database-utils.js"; import { flushOpenDocumentEditorToSql } from "./_document-flush.js"; /** @@ -62,7 +63,13 @@ export default defineAction({ publicAgent: { expose: true, readOnly: true, requiresAuth: true }, run: async ({ id, format }) => { const access = await resolveAccess("document", id); - if (!access) throw new Error(`Document "${id}" not found`); + if ( + !access || + access.resource.trashedAt || + (await isSoftDeletedDatabaseDocument(id)) + ) { + throw new Error(`Document "${id}" not found`); + } // If a live Yjs collab session is open, the in-memory editor doc is fresher // than the SQL column. Ask the open editor to serialize + save, then wait @@ -74,7 +81,13 @@ export default defineAction({ // Re-resolve so we read the now-fresh row (and re-check access). const fresh = await resolveAccess("document", id); - if (!fresh) throw new Error(`Document "${id}" not found`); + if ( + !fresh || + fresh.resource.trashedAt || + (await isSoftDeletedDatabaseDocument(id)) + ) { + throw new Error(`Document "${id}" not found`); + } const doc = fresh.resource; const markdown = (doc.content as string) ?? ""; const content = formatDocumentContent(markdown, format); diff --git a/templates/content/actions/restore-content-database.ts b/templates/content/actions/restore-content-database.ts index 022c83b742..5eb3e80146 100644 --- a/templates/content/actions/restore-content-database.ts +++ b/templates/content/actions/restore-content-database.ts @@ -1,7 +1,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { resolveAccess } from "@agent-native/core/sharing"; -import { eq } from "drizzle-orm"; +import { and, eq, isNotNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -9,6 +9,7 @@ import { assertContentDatabaseLifecycleAccess, collectInlineDatabaseOwnerBlockIds, } from "./_content-database-lifecycle.js"; +import { restoreDocumentSubtree } from "./delete-document.js"; import pullDocumentAction from "./pull-document.js"; async function shouldClearStaleInlineOwnership(args: { @@ -47,16 +48,52 @@ export default defineAction({ ownerBlockId: ownership.database.ownerBlockId, }); - await db - .update(schema.contentDatabases) - .set({ - deletedAt: null, - updatedAt: now, - ...(clearInlineOwnership - ? { ownerDocumentId: null, ownerBlockId: null } - : {}), - }) - .where(eq(schema.contentDatabases.id, databaseId)); + await db.transaction(async (tx) => { + const [backingDocument] = await tx + .select({ + trashedAt: schema.documents.trashedAt, + trashRootId: schema.documents.trashRootId, + }) + .from(schema.documents) + .where(eq(schema.documents.id, ownership.database.documentId)) + .limit(1); + if (!backingDocument) { + throw new Error(`Database "${databaseId}" not found`); + } + if ( + backingDocument.trashedAt && + backingDocument.trashRootId !== ownership.database.documentId + ) { + throw new Error("Restore the parent Trash item instead"); + } + + const restoredDocumentIds = await restoreDocumentSubtree( + tx as unknown as ReturnType, + ownership.database.documentId, + ownership.database.ownerEmail, + ); + if ( + backingDocument.trashedAt && + !restoredDocumentIds.includes(ownership.database.documentId) + ) { + throw new Error("Database backing page was not restored"); + } + await tx + .update(schema.contentDatabases) + .set({ + deletedAt: null, + updatedAt: now, + ...(clearInlineOwnership + ? { ownerDocumentId: null, ownerBlockId: null } + : {}), + }) + .where( + and( + eq(schema.contentDatabases.id, databaseId), + isNotNull(schema.contentDatabases.deletedAt), + ), + ); + }); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/restore-document.ts b/templates/content/actions/restore-document.ts new file mode 100644 index 0000000000..68a8a26a3c --- /dev/null +++ b/templates/content/actions/restore-document.ts @@ -0,0 +1,28 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { assertAccess } from "@agent-native/core/sharing"; +import { z } from "zod"; + +import { getDb } from "../server/db/index.js"; +import { restoreDocumentSubtree } from "./delete-document.js"; + +export default defineAction({ + description: "Restore a page subtree from Trash.", + schema: z.object({ + id: z.string().describe("Trashed root document ID"), + }), + run: async ({ id }) => { + const access = await assertAccess("document", id, "admin"); + const restored = await getDb().transaction((tx) => + restoreDocumentSubtree( + tx as unknown as ReturnType, + id, + access.resource.ownerEmail as string, + ), + ); + if (restored.length === 0) throw new Error("Document is not in Trash"); + + await writeAppState("refresh-signal", { ts: Date.now() }); + return { success: true, restored: restored.length, documentId: id }; + }, +}); diff --git a/templates/content/actions/search-documents.ts b/templates/content/actions/search-documents.ts index ce67ed5630..17850c4710 100644 --- a/templates/content/actions/search-documents.ts +++ b/templates/content/actions/search-documents.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { accessFilter } from "@agent-native/core/sharing"; -import { and, sql } from "drizzle-orm"; +import { and, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -74,6 +74,7 @@ export default defineAction({ .where( and( accessFilter(schema.documents, schema.documentShares), + isNull(schema.documents.trashedAt), documentDiscoveryFilter(), sql`(${schema.documents.title} LIKE ${pattern} ESCAPE '\\' OR ${schema.documents.description} LIKE ${pattern} ESCAPE '\\' OR ${schema.documents.content} LIKE ${pattern} ESCAPE '\\')`, ), diff --git a/templates/content/actions/view-screen.ts b/templates/content/actions/view-screen.ts index 8ad90e0a87..7679f13f61 100644 --- a/templates/content/actions/view-screen.ts +++ b/templates/content/actions/view-screen.ts @@ -4,7 +4,7 @@ import { readAppStateForCurrentTab, } from "@agent-native/core/application-state"; import { accessFilter, resolveAccess } from "@agent-native/core/sharing"; -import { and, asc, inArray } from "drizzle-orm"; +import { and, asc, inArray, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -765,6 +765,7 @@ export default defineAction({ .where( and( accessFilter(schema.documents, schema.documentShares), + isNull(schema.documents.trashedAt), documentDiscoveryFilter(), ), ) diff --git a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts index 0b5fb941af..f3b152a20a 100644 --- a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts +++ b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts @@ -27,6 +27,9 @@ describe("Files database local-folder source entry", () => { expect(localFilesRoute).toContain( "createSourceBackedSpace: !targetSpaceId && !targetDatabaseId", ); + expect(localFilesRoute).toContain( + "propertyValues: workspacePropertyValues", + ); }); it("blocks unsafe embedded hosts from the native directory picker", () => { diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 4eb24db3a2..2e4264d78a 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -157,6 +157,7 @@ import { SELECTED_CONTENT_SPACE_STORAGE_KEY, selectContentSpace, } from "@/components/sidebar/select-content-space"; +import { WorkspaceSourceMenu } from "@/components/sidebar/WorkspaceSourceMenu"; import { isContentDatabaseUnavailable, useAddDatabaseItem, @@ -188,7 +189,6 @@ import { } from "@/hooks/use-content-database"; import { useContentSpaces, - useCreateContentSpace, useDeleteContentSpace, } from "@/hooks/use-content-spaces"; import { @@ -729,8 +729,6 @@ function DatabaseTable({ ); const database = useContentDatabase(document.id, databaseRequestItemLimit); const addItem = useAddDatabaseItem(document.id); - const createContentSpace = useCreateContentSpace(); - const workspaceCreateRequestIdRef = useRef(null); const attachSource = useAttachContentDatabaseSource(document.id); const changeSourceRole = useChangeContentDatabaseSourceRole(document.id); const refreshSource = useRefreshContentDatabaseSource(document.id); @@ -750,10 +748,8 @@ function DatabaseTable({ const data = isContentDatabaseUnavailable(database.data) ? undefined : database.data; - const isCreatingDatabaseItem = - data?.database.systemRole === "workspaces" - ? createContentSpace.isPending - : addItem.isPending; + const isWorkspaceCatalog = data?.database.systemRole === "workspaces"; + const isCreatingDatabaseItem = addItem.isPending; const isDatabaseInitialLoading = database.isLoading && !data; const properties = data?.properties ?? []; const items = data?.items ?? []; @@ -764,10 +760,9 @@ function DatabaseTable({ const isLoadingMoreItems = database.isFetching && data?.pagination?.limit !== databaseRequestItemLimit; const databaseId = data?.database.id ?? expectedDatabaseId; - const newDatabaseRowLabel = - data?.database.systemRole === "workspaces" - ? t("sidebar.newWorkspace") - : dbText("newPage"); + const newDatabaseRowLabel = isWorkspaceCatalog + ? t("sidebar.addWorkspace") + : dbText("newPage"); const personalView = useContentDatabasePersonalView(databaseId); const updatePersonalView = useUpdateContentDatabasePersonalView(databaseId); const source = data?.source ?? null; @@ -847,6 +842,9 @@ function DatabaseTable({ const filters = activeView.filters; const visibleFilters = filters; const filterMode = activeView.filterMode ?? "and"; + const workspaceCreationPropertyValues = isWorkspaceCatalog + ? databasePropertyValuesForNewItem(filters, properties, filterMode) + : undefined; const columnWidths = activeView.columnWidths; const databaseGroupProperty = useMemo( () => databaseViewGroupingProperty(activeView, orderedProperties), @@ -1427,29 +1425,7 @@ function DatabaseTable({ } = {}, ) { if (!databaseId) return null; - if (data?.database.systemRole === "workspaces") { - const name = title.trim() || t("sidebar.newWorkspace"); - const requestId = - workspaceCreateRequestIdRef.current ?? crypto.randomUUID(); - workspaceCreateRequestIdRef.current = requestId; - try { - await createContentSpace.mutateAsync({ - name, - requestId, - propertyValues: - Object.keys(propertyValueOverrides).length > 0 - ? propertyValueOverrides - : undefined, - }); - workspaceCreateRequestIdRef.current = null; - } catch (err) { - toast.error(dbText("failedToCreateRow"), { - description: - err instanceof Error ? err.message : dbText("somethingWentWrong"), - }); - } - return null; - } + if (isWorkspaceCatalog) return null; const propertyValues = { ...databasePropertyValuesForNewItem(filters, properties, filterMode), ...propertyValueOverrides, @@ -2417,7 +2393,21 @@ function DatabaseTable({ ) : null} - {canEdit ? ( + {canEdit && isWorkspaceCatalog ? ( + + + + ) : canEdit ? ( + + ); +} + function NewDatabaseRow({ label, properties, @@ -17831,6 +17902,7 @@ function DatabaseGroupedTableSection({ columnWidths, databaseDocumentId, workspaceCatalog, + workspaceCreationPropertyValues, canEdit, selectedIdSet, wrapCells, @@ -17853,6 +17925,7 @@ function DatabaseGroupedTableSection({ columnWidths: Record; databaseDocumentId: string; workspaceCatalog: boolean; + workspaceCreationPropertyValues?: Record; canEdit: boolean; selectedIdSet: Set; wrapCells: boolean; @@ -17918,15 +17991,36 @@ function DatabaseGroupedTableSection({ /> ))} {canEdit ? ( - onCreateRow(group, title)} - /> + workspaceCatalog ? ( + + ) : ( + onCreateRow(group, title)} + /> + ) ) : null} ) : null} diff --git a/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts index aab464a7cb..4e2603fddd 100644 --- a/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts +++ b/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts @@ -3,21 +3,30 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; describe("Workspaces database lifecycle", () => { - it("routes every create surface through workspace semantics and shared pending state", () => { + it("routes every create surface through the shared workspace source chooser", () => { const source = readFileSync( new URL("./DatabaseView.tsx", import.meta.url), "utf8", ); - expect(source).toContain('data?.database.systemRole === "workspaces"'); - expect(source).toContain("createContentSpace.mutateAsync"); + expect(source).toContain( + 'const isWorkspaceCatalog = data?.database.systemRole === "workspaces"', + ); + expect(source).toContain( + 'import { WorkspaceSourceMenu } from "@/components/sidebar/WorkspaceSourceMenu"', + ); + expect(source.match(/)/g)).toHaveLength(2); + expect(source.match(/ onOpenPage(item)} /> ))} - {canEdit && !grouped ? ( + {canEdit && canCreateItems && !grouped ? ( onOpenPage(item)} /> ))} - {canEdit ? ( + {canEdit && canCreateItems ? ( onOpenPage(item)} /> ))} - {canEdit && !grouped ? ( + {canEdit && canCreateItems && !grouped ? ( onOpenPage(item)} /> ))} - {canEdit ? ( + {canEdit && canCreateItems ? ( { expect(markup).toContain('aria-current="page"'); }); + it("does not promote a child when sorting places it before its parent", () => { + const parent = item("parent", "Zulu parent"); + const child = item("child", "Alpha child", "parent"); + const sortedItems = [child, parent]; + + expect( + databaseSidebarRootItems(sortedItems, sortedItems).map( + (candidate) => candidate.document.id, + ), + ).toEqual(["parent"]); + + expect( + databaseSidebarItemTree( + databaseSidebarRootItems(sortedItems, sortedItems), + sortedItems, + ), + ).toMatchObject([ + { + item: { document: { id: "parent" } }, + children: [{ item: { document: { id: "child" } } }], + }, + ]); + }); + + it("does not promote a matching child when its existing parent is filtered out", () => { + const parent = item("parent", "Parent"); + const child = item("child", "Matching child", "parent"); + + expect(databaseSidebarRootItems([child], [parent, child])).toEqual([]); + }); + + it("keeps a true orphan visible as a root", () => { + const orphan = item("orphan", "Orphan", "deleted-parent"); + + expect(databaseSidebarRootItems([orphan], [orphan])).toEqual([orphan]); + }); + + it("does not promote a database row when its database page is in Files", () => { + const databasePage = item("database-page", "Database"); + const databaseRow = item("row", "Database row", "database-page"); + databaseRow.document.databaseMembership = { + databaseId: "database", + databaseDocumentId: "database-page", + databaseTitle: "Database", + position: 0, + }; + + expect( + databaseSidebarRootItems( + [databaseRow, databasePage], + [databaseRow, databasePage], + ).map((candidate) => candidate.document.id), + ).toEqual(["database-page"]); + }); + it("reveals descendants only after their parent is explicitly expanded", async () => { const rootItem = item("parent", "Page one"); const childItem = item("child", "Page two", "parent"); @@ -261,6 +317,63 @@ describe("DatabaseSidebarView", () => { expect(markup).not.toContain(">Child<"); }); + it("does not promote a matching child whose parent is filtered out", () => { + const parent = item("parent", "Parent"); + const child = item("child", "Matching child", "parent"); + const data = { + database: { + viewConfig: { + version: 1, + activeViewId: "default", + views: [ + { + id: "default", + name: "Table", + type: "table", + filters: [ + { + key: "name", + label: "Name", + operator: "contains", + value: "Matching", + }, + ], + sorts: [], + filterMode: "and", + }, + ], + }, + }, + items: [parent, child], + properties: [ + { + definition: { id: "parent", systemRole: "files_parent" }, + }, + ], + } as unknown as ContentDatabaseResponse; + + const markup = renderToStaticMarkup( + + + + + , + ); + + expect(markup).not.toContain("Matching child"); + }); + it("lets a saved database view render workspace roots inside its groups", () => { const groups = [ { diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index 532c951379..9f919d948f 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -172,11 +172,11 @@ export function ContentFilesSidebarView({ ), activeView.hideEmptyGroups === true, ); - const hierarchyItems = data?.properties.some( + const hasFilesHierarchy = data?.properties.some( (property) => property.definition.systemRole === "files_parent", - ) - ? items - : undefined; + ); + const hierarchyItems = hasFilesHierarchy ? items : undefined; + const hierarchyUniverseItems = hasFilesHierarchy ? data?.items : undefined; return (
{viewConfig.views.length > 1 && ( @@ -223,6 +223,7 @@ export function ContentFilesSidebarView({ onDocumentExpandedChange={onDocumentExpandedChange} renderItem={renderItem} hierarchyItems={hierarchyItems} + hierarchyUniverseItems={hierarchyUniverseItems} scroll={scroll} />
@@ -247,6 +248,7 @@ export function DatabaseSidebarView({ onDocumentExpandedChange, renderItem, hierarchyItems, + hierarchyUniverseItems, scroll = true, loadingLabel, noMatchesLabel, @@ -271,6 +273,7 @@ export function DatabaseSidebarView({ onDocumentExpandedChange?: (documentId: string, expanded: boolean) => void; renderItem?: (item: ContentDatabaseItem) => ReactNode; hierarchyItems?: ContentDatabaseItem[]; + hierarchyUniverseItems?: ContentDatabaseItem[]; scroll?: boolean; loadingLabel: string; noMatchesLabel: string; @@ -287,7 +290,13 @@ export function DatabaseSidebarView({ const items = groups.flatMap((group) => group.items); const itemTree = !grouped && hierarchyItems - ? databaseSidebarItemTree(items, hierarchyItems) + ? databaseSidebarItemTree( + databaseSidebarRootItems( + items, + hierarchyUniverseItems ?? hierarchyItems, + ), + hierarchyItems, + ) : null; function setGroupOpen(groupId: string, open: boolean) { @@ -690,6 +699,18 @@ export interface DatabaseSidebarItemTreeNode { children: DatabaseSidebarItemTreeNode[]; } +export function databaseSidebarRootItems( + visibleItems: ContentDatabaseItem[], + allItems: ContentDatabaseItem[], +) { + const documentIds = new Set(allItems.map((item) => item.document.id)); + return visibleItems.filter((item) => { + const parentId = item.document.parentId; + if (!parentId) return true; + return !documentIds.has(parentId); + }); +} + export function databaseSidebarRowIndent(depth: number, _hasChildren: boolean) { return depth * 18; } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index af5d8e8977..f2a5ee2d0e 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -190,10 +190,24 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("expandedDocumentIds={expandedDocumentIdSet}"); expect(sidebar).toContain("toggleExpandedWorkspaceIds(current, space.id)"); expect(sidebar).toContain("ensureWorkspaceExpanded(current, space.id)"); + expect(sidebar).not.toContain( + "if (!selectedSpace || !sidebarStateHydratedRef.current) return", + ); + expect(sidebar).toContain("createContentSidebarStateWriteQueue"); + expect(sidebar).not.toContain("sidebarStateWriteTimerRef"); + expect(sidebar).toContain( + 'toast.error(t("sidebar.failedSaveSidebarState")', + ); expect(sidebar).toContain( '"group/workspace-header flex h-7 w-full min-w-0 items-center rounded-md"', ); expect(sidebar).toContain("group-hover/workspace-header:opacity-100"); + expect(sidebar).toContain( + "group-focus-visible/workspace-toggle:opacity-100", + ); + expect(sidebar).not.toContain( + "group-focus-within/workspace-header:opacity-100", + ); expect(sidebar).not.toContain('className="group/workspace min-w-0"'); expect(sidebar).toContain("{expanded && ("); expect(sidebar).toContain(" { expect(sidebar).not.toContain( '
', ); - expect(sidebar).not.toContain(""); + expect(sidebar).toContain( + 'import { OrgSwitcher } from "@agent-native/core/client/org";', + ); + expect(sidebar).toContain(""); + expect(sidebar.indexOf("")).toBeLessThan( + sidebar.indexOf(""), + ); + expect(sidebar.indexOf("")).toBeLessThan( + sidebar.indexOf("{/* Footer */}"), + ); expect(sidebar).toContain('t("sidebar.addWorkspace")'); - expect(sidebar).toContain('t("sidebar.newWorkspace")'); - expect(sidebar).toContain("useCreateContentSpace"); - expect(sidebar).toContain("handleCreateWorkspace"); + expect(sidebar).toContain(""); expect(sidebar).toContain("name: item.document.title || space.name"); expect(sidebar).toContain("scroll={false}"); - expect(sidebar).toContain(''); }); - it("keeps the trashed inline database lifecycle visible in the sidebar", () => { + it("keeps a unified page and database Trash lifecycle visible in the sidebar", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); const messages = readSidebarSource("../../i18n-data.ts"); expect(sidebar).toContain("useTrashedContentDatabases"); + expect(sidebar).toContain("useTrashedDocuments"); expect(sidebar).toContain("useDeleteContentDatabase"); expect(sidebar).toContain("useRestoreContentDatabase"); expect(sidebar).toContain("const trashItems ="); + expect(sidebar).toContain("const trashedPageItems ="); + expect(sidebar).toContain("const handleRestoreDocument = useCallback"); + expect(sidebar).toContain( + "const handlePermanentDeleteDocument = useCallback", + ); expect(sidebar).toContain("const handleRestoreDatabase = useCallback"); expect(sidebar).toContain( "const handlePermanentDeleteDatabase = useCallback", ); expect(sidebar).toContain("const renderTrashSection = () =>"); - expect(sidebar).toContain( - 'renderSectionHeader("trash", t("sidebar.trash"))', - ); + expect(sidebar).toContain("trash: true"); + expect(sidebar).toContain("value?.trash ?? true"); + expect(sidebar).toContain("TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY"); + expect(sidebar).toContain('toggleSection("trash")'); + expect(sidebar).toContain(" { expect(messages).toContain('trash: "Trash"'); expect(messages).toContain('restoreDatabase: "Restore"'); + expect(messages).toContain('restorePage: "Restore"'); + expect(messages).toContain('trashEmpty: "Trash is empty"'); expect(messages).toContain( 'deleteDatabasePermanentlyQuestion: "Delete database permanently?"', ); @@ -325,6 +363,10 @@ describe("document sidebar layout", () => { expect(sidebar).toContain('toggleSection("favorites")'); expect(sidebar).toContain("!collapsedSections.favorites &&"); expect(sidebar).toContain("aria-expanded={!collapsedSections.favorites}"); + expect(sidebar).toContain("; const SIDEBAR_SECTION_COLLAPSE_STORAGE_KEY = "content-sidebar-collapsed-sections"; +const TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY = + "content-sidebar-trash-collapsed-default-v2"; const CONTENT_SIDEBAR_STATE_VERSION = 1 as const; +interface ContentSidebarStateSnapshot { + version: typeof CONTENT_SIDEBAR_STATE_VERSION; + expandedWorkspaceIds: string[]; + expandedDocumentIds: string[]; +} const DEFAULT_COLLAPSED_SECTIONS: CollapsedSectionsState = { favorites: false, "local-files": false, "shared-copies": false, private: false, organization: false, - trash: false, + trash: true, }; function normalizeCollapsedSections( @@ -231,7 +238,7 @@ function normalizeCollapsedSections( "shared-copies": value?.["shared-copies"] ?? false, private: value?.private ?? false, organization: value?.organization ?? false, - trash: value?.trash ?? false, + trash: value?.trash ?? true, }; } @@ -351,13 +358,15 @@ export function DocumentSidebar({ const createDatabase = useCreateContentDatabase(null); const deleteContentDatabase = useDeleteContentDatabase(); const deleteDocument = useDeleteDocument(); + const permanentlyDeleteDocument = usePermanentlyDeleteDocument(); const moveDocument = useMoveDocument(); + const restoreDocument = useRestoreDocument(); + const { data: trashedDocuments } = useTrashedDocuments(); const restoreContentDatabase = useRestoreContentDatabase(); const { data: trashedDatabases } = useTrashedContentDatabases(); const { isCodeMode } = useCodeMode(); const updateDocument = useUpdateDocument(); const contentSpacesQuery = useContentSpaces(); - const createContentSpace = useCreateContentSpace(); const ensureContentSpaces = useEnsureContentSpaces(); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; @@ -406,6 +415,17 @@ export function DocumentSidebar({ ); }, }); + const updateSidebarStateRef = useRef(updateSidebarState); + updateSidebarStateRef.current = updateSidebarState; + const sidebarStateWriteQueueRef = useRef< + ((snapshot: ContentSidebarStateSnapshot) => Promise) | null + >(null); + if (!sidebarStateWriteQueueRef.current) { + sidebarStateWriteQueueRef.current = createContentSidebarStateWriteQueue( + (snapshot: ContentSidebarStateSnapshot) => + updateSidebarStateRef.current.mutateAsync(snapshot), + ); + } const [expandedWorkspaceIds, setExpandedWorkspaceIds] = useState( [], ); @@ -417,13 +437,6 @@ export function DocumentSidebar({ const sidebarStateHydratedRef = useRef(false); const expandedWorkspaceIdsRef = useRef([]); const expandedDocumentIdsRef = useRef([]); - const sidebarStateWriteTimerRef = useRef | null>(null); - const [createWorkspaceDialogOpen, setCreateWorkspaceDialogOpen] = - useState(false); - const [newWorkspaceName, setNewWorkspaceName] = useState(""); - const createWorkspaceRequestIdRef = useRef(null); const contentSpaceState = contentSpaceAvailability({ hasSelectedSpace: Boolean(selectedSpace), contentSpacesLoading: contentSpacesQuery.isLoading, @@ -474,19 +487,19 @@ export function DocumentSidebar({ const queueSidebarStateWrite = useCallback( (workspaceIds: string[], documentIds: string[]) => { if (!sidebarStateHydratedRef.current) return; - if (sidebarStateWriteTimerRef.current) { - clearTimeout(sidebarStateWriteTimerRef.current); - } - sidebarStateWriteTimerRef.current = setTimeout(() => { - sidebarStateWriteTimerRef.current = null; - updateSidebarState.mutate({ + void sidebarStateWriteQueueRef + .current?.({ version: CONTENT_SIDEBAR_STATE_VERSION, expandedWorkspaceIds: workspaceIds, expandedDocumentIds: documentIds, + }) + .catch((error) => { + toast.error(t("sidebar.failedSaveSidebarState"), { + description: error instanceof Error ? error.message : String(error), + }); }); - }, 150); }, - [updateSidebarState], + [t], ); const updateExpandedWorkspaceIds = useCallback( @@ -517,21 +530,6 @@ export function DocumentSidebar({ [queueSidebarStateWrite], ); - useEffect( - () => () => { - if (sidebarStateWriteTimerRef.current) { - clearTimeout(sidebarStateWriteTimerRef.current); - } - }, - [], - ); - - useEffect(() => { - if (!selectedSpace || !sidebarStateHydratedRef.current) return; - updateExpandedWorkspaceIds((current) => - ensureWorkspaceExpanded(current, selectedSpace.id), - ); - }, [selectedSpace, updateExpandedWorkspaceIds]); const handleSelectContentSpace = useCallback( async ( space: (typeof contentSpaces)[number], @@ -572,14 +570,9 @@ export function DocumentSidebar({ }, [navigate, setStoredSpaceId, updateExpandedWorkspaceIds], ); - const handleCreateWorkspace = useCallback(async () => { - const name = newWorkspaceName.trim(); - if (!name) return; - const requestId = createWorkspaceRequestIdRef.current ?? nanoid(); - createWorkspaceRequestIdRef.current = requestId; - try { - const created = await createContentSpace.mutateAsync({ name, requestId }); - const space: ContentSpaceSummary = { + const handleWorkspaceCreated = useCallback( + (created: CreatedWorkspace) => + handleSelectContentSpace({ id: created.spaceId, name: created.name, kind: created.kind, @@ -589,18 +582,9 @@ export function DocumentSidebar({ role: "owner", catalogItemId: created.catalogItemId, catalogDocumentId: created.catalogDocumentId, - }; - const selected = await handleSelectContentSpace(space); - if (!selected) return; - setCreateWorkspaceDialogOpen(false); - setNewWorkspaceName(""); - createWorkspaceRequestIdRef.current = null; - } catch (error) { - toast.error(t("sidebar.failedCreateWorkspace"), { - description: error instanceof Error ? error.message : String(error), - }); - } - }, [createContentSpace, handleSelectContentSpace, newWorkspaceName, t]); + }), + [handleSelectContentSpace], + ); useEffect(() => { if (!selectedSpace) return; void setClientAppState( @@ -634,6 +618,21 @@ export function DocumentSidebar({ () => normalizeCollapsedSections(storedCollapsedSections), [storedCollapsedSections], ); + useEffect(() => { + try { + if ( + window.localStorage.getItem(TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY) === + "1" + ) { + return; + } + setStoredCollapsedSections((current) => ({ + ...normalizeCollapsedSections(current), + trash: true, + })); + window.localStorage.setItem(TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY, "1"); + } catch {} + }, [setStoredCollapsedSections]); const [removeLocalFilesDialogOpen, setRemoveLocalFilesDialogOpen] = useState(false); const agentActive = location.pathname.startsWith("/agent"); @@ -700,6 +699,7 @@ export function DocumentSidebar({ ? documents.find((doc) => doc.id === activeDocumentId) : null; const trashItems = trashedDatabases?.databases ?? []; + const trashedPageItems = trashedDocuments?.documents ?? []; const parentByDocumentId = useMemo( () => new Map(documents.map((doc) => [doc.id, doc.parentId])), [documents], @@ -1124,7 +1124,7 @@ export function DocumentSidebar({ const handlePermanentDeleteDatabase = useCallback( async (documentId: string) => { try { - await deleteDocument.mutateAsync({ id: documentId }); + await permanentlyDeleteDocument.mutateAsync({ id: documentId }); queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); @@ -1139,7 +1139,37 @@ export function DocumentSidebar({ }); } }, - [deleteDocument, queryClient, t], + [permanentlyDeleteDocument, queryClient, t], + ); + + const handleRestoreDocument = useCallback( + async (documentId: string) => { + try { + await restoreDocument.mutateAsync({ id: documentId }); + toast.success(t("sidebar.pageRestored")); + } catch (err) { + toast.error(t("sidebar.failedRestorePage"), { + description: + err instanceof Error ? err.message : t("empty.genericError"), + }); + } + }, + [restoreDocument, t], + ); + + const handlePermanentDeleteDocument = useCallback( + async (documentId: string) => { + try { + await permanentlyDeleteDocument.mutateAsync({ id: documentId }); + toast.success(t("sidebar.pagePermanentlyDeleted")); + } catch (err) { + toast.error(t("sidebar.failedPermanentDeletePage"), { + description: + err instanceof Error ? err.message : t("empty.genericError"), + }); + } + }, + [permanentlyDeleteDocument, t], ); const handleRemoveLocalFiles = useCallback(async () => { @@ -1423,21 +1453,21 @@ export function DocumentSidebar({ type="button" aria-expanded={expanded} aria-label={`${expanded ? t("sidebar.collapse") : t("sidebar.expand")} ${space.name}`} - className="relative flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-background/60" + className="group/workspace-toggle relative flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-background/60" onClick={() => updateExpandedWorkspaceIds((current) => toggleExpandedWorkspaceIds(current, space.id), ) } > - + {expanded ? ( ) : ( )} - + {expanded ? ( ) : ( @@ -1555,37 +1585,20 @@ export function DocumentSidebar({ /> )}
- - - - - - setCreateWorkspaceDialogOpen(true)} - > - - {t("sidebar.newWorkspace")} - - - - - - {t("sidebar.localFolder")} - - - - + + +
) : contentSpaceState === "loading" ? ( @@ -1605,14 +1618,123 @@ export function DocumentSidebar({ ); const renderTrashSection = () => { - if (trashItems.length === 0) return null; const collapsed = collapsedSections.trash; return (
- {renderSectionHeader("trash", t("sidebar.trash"))} +
+ +
{!collapsed && (
+ {trashedPageItems.map((document) => { + const title = document.title || t("sidebar.untitled"); + return ( +
+ {title} + + + + + {t("sidebar.restorePage")} + + + + + + + + + + {t("sidebar.deletePermanently")} + + + + + + {t("sidebar.deletePagePermanentlyQuestion")} + + + {t("sidebar.deletePagePermanentlyDescription", { + title, + })} + + + + + {t("comments.cancel")} + + + void handlePermanentDeleteDocument( + document.documentId, + ) + } + > + {t("sidebar.deletePermanently")} + + + + +
+ ); + })} + {trashedPageItems.length === 0 && trashItems.length === 0 ? ( +
+ {t("sidebar.trashEmpty")} +
+ ) : null} {trashItems.map((database) => { const title = database.title || t("editor.untitledDatabase"); return ( @@ -1653,7 +1775,7 @@ export function DocumentSidebar({ { title }, )} className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:opacity-50" - disabled={deleteDocument.isPending} + disabled={permanentlyDeleteDocument.isPending} > @@ -1873,19 +1995,27 @@ export function DocumentSidebar({ {/* Favorites */} {showFavorites && (
-
+
+
+ +
+ {/* Footer */}
{isCodeMode ? : null} @@ -1978,60 +2112,6 @@ export function DocumentSidebar({ onMouseDown={handleMouseDown} /> )} - { - setCreateWorkspaceDialogOpen(open); - if (!open && !createContentSpace.isPending) { - setNewWorkspaceName(""); - createWorkspaceRequestIdRef.current = null; - } - }} - > - -
{ - event.preventDefault(); - void handleCreateWorkspace(); - }} - > - - {t("sidebar.newWorkspace")} - - {t("sidebar.newWorkspaceDescription")} - - - setNewWorkspaceName(event.target.value)} - /> - - - - -
-
-
{ + it("offers the same blank and local-folder sources to every trigger", () => { + const source = readFileSync( + new URL("./WorkspaceSourceMenu.tsx", import.meta.url), + "utf8", + ); + + expect(source).toContain('t("sidebar.newWorkspace")'); + expect(source).toContain('to="/local-files"'); + expect(source).toContain( + "state={{ workspacePropertyValues: propertyValues }}", + ); + expect(source).toContain('t("sidebar.localFolder")'); + expect(source).toContain("createContentSpace.mutateAsync"); + expect(source).toContain("propertyValues,"); + expect(source).toContain("const accepted = await onCreated?.(created)"); + expect(source).toContain("if (accepted === false) return"); + }); +}); diff --git a/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx b/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx new file mode 100644 index 0000000000..dcec70e80b --- /dev/null +++ b/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx @@ -0,0 +1,154 @@ +import { useT } from "@agent-native/core/client/i18n"; +import { IconFolder, IconPlus } from "@tabler/icons-react"; +import { useRef, useState, type ReactElement } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { useCreateContentSpace } from "@/hooks/use-content-spaces"; + +export type CreatedWorkspace = { + spaceId: string; + filesDatabaseId: string; + filesDocumentId: string; + catalogDatabaseId: string; + catalogItemId: string; + catalogDocumentId: string; + name: string; + kind: "user"; +}; + +export function WorkspaceSourceMenu({ + children, + align = "start", + propertyValues, + onCreated, +}: { + children: ReactElement; + align?: "start" | "center" | "end"; + propertyValues?: Record; + onCreated?: ( + workspace: CreatedWorkspace, + ) => boolean | void | Promise; +}) { + const t = useT(); + const createContentSpace = useCreateContentSpace(); + const [dialogOpen, setDialogOpen] = useState(false); + const [name, setName] = useState(""); + const requestIdRef = useRef(null); + + async function createWorkspace() { + const workspaceName = name.trim(); + if (!workspaceName) return; + const requestId = requestIdRef.current ?? crypto.randomUUID(); + requestIdRef.current = requestId; + try { + const created = await createContentSpace.mutateAsync({ + name: workspaceName, + requestId, + propertyValues, + }); + const accepted = await onCreated?.(created); + if (accepted === false) return; + setDialogOpen(false); + setName(""); + requestIdRef.current = null; + } catch (error) { + toast.error(t("sidebar.failedCreateWorkspace"), { + description: error instanceof Error ? error.message : String(error), + }); + } + } + + return ( + <> + + {children} + + setDialogOpen(true)}> + + {t("sidebar.newWorkspace")} + + + + + + {t("sidebar.localFolder")} + + + + + + { + setDialogOpen(open); + if (!open && !createContentSpace.isPending) { + setName(""); + requestIdRef.current = null; + } + }} + > + +
{ + event.preventDefault(); + void createWorkspace(); + }} + > + + {t("sidebar.newWorkspace")} + + {t("sidebar.newWorkspaceDescription")} + + + setName(event.target.value)} + /> + + + + +
+
+
+ + ); +} diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index 9eb646f56f..a12e7c9cbf 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -7,6 +7,7 @@ import { contentSpaceForStoredSelection, contentSpaceForCatalogItem, contentSpaceIdForCreate, + createContentSidebarStateWriteQueue, createContentSpaceSelectionQueue, ensureWorkspaceExpanded, selectContentSpace, @@ -276,6 +277,29 @@ describe("workspace expansion", () => { const expanded = ["personal", "organization"]; expect(ensureWorkspaceExpanded(expanded, "organization")).toBe(expanded); }); + + it("serializes persisted expansion snapshots in interaction order", async () => { + const writes: string[][] = []; + let releaseFirst!: () => void; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + const enqueue = createContentSidebarStateWriteQueue( + async (workspaceIds: string[]) => { + if (writes.length === 0) await firstPending; + writes.push(workspaceIds); + }, + ); + + const first = enqueue(["personal", "organization"]); + const second = enqueue(["personal"]); + await Promise.resolve(); + expect(writes).toEqual([]); + releaseFirst(); + await Promise.all([first, second]); + + expect(writes).toEqual([["personal", "organization"], ["personal"]]); + }); }); describe("contentSpaceIdForCreate", () => { diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index de0607baec..8bb7311d38 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -104,3 +104,14 @@ export function createContentSpaceSelectionQueue() { return next; }; } + +export function createContentSidebarStateWriteQueue( + write: (snapshot: T) => Promise, +) { + let pending: Promise = Promise.resolve(); + return (snapshot: T) => { + const next = pending.catch(() => undefined).then(() => write(snapshot)); + pending = next; + return next; + }; +} diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index b4afac6bce..cb37c67235 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -622,6 +622,9 @@ export function useDeleteContentDatabase() { >("delete-content-database", { onSuccess: (data) => { clearDeletedContentDatabaseFromCache(queryClient, data.documentId); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); }, }); } @@ -650,6 +653,9 @@ export function useRestoreContentDatabase() { queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-content-databases"], }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); queryClient.invalidateQueries({ queryKey: ["action", "list-content-databases"], }); diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index 9d387b2643..4f6f0b5c2d 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -11,6 +11,7 @@ import type { DocumentUpdateRequest, DocumentUpdateResponse, DocumentMoveRequest, + ListTrashedDocumentsResponse, DocumentTreeNode, } from "@shared/api"; import type { QueryClient } from "@tanstack/react-query"; @@ -534,6 +535,62 @@ export function useDeleteDocument() { queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-content-databases"], }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); + }, + }); +} + +export function useTrashedDocuments() { + return useActionQuery( + "list-trashed-documents", + {}, + ); +} + +export function useRestoreDocument() { + const queryClient = useQueryClient(); + return useActionMutation< + { success: boolean; restored: number; documentId: string }, + { id: string } + >("restore-document", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["action", "list-documents"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-content-databases"], + }); + }, + }); +} + +export function usePermanentlyDeleteDocument() { + const queryClient = useQueryClient(); + return useActionMutation< + { success: boolean; deleted: number }, + { id: string } + >("permanently-delete-document", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["action", "list-documents"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-content-databases"], + }); }, }); } diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 9f48af77ea..0524b96cd0 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3108,24 +3108,33 @@ const enUS = { database: "Database", databasePermanentlyDeleted: "Database permanently deleted", databaseRestored: "Database restored", + pagePermanentlyDeleted: "Page permanently deleted", + pageRestored: "Page restored", deleteDatabaseNamedPermanently: "Delete {{title}} permanently", deleteDatabasePermanentlyDescription: "This permanently deletes “{{title}}” and its pages. This cannot be undone.", deleteDatabasePermanentlyQuestion: "Delete database permanently?", + deletePageNamedPermanently: "Delete {{title}} permanently", + deletePagePermanentlyDescription: + "This permanently deletes “{{title}}” and its sub-pages. This cannot be undone.", + deletePagePermanentlyQuestion: "Delete page permanently?", deletePermanently: "Delete permanently", failedCreateDatabase: "Failed to create database", failedCreatePage: "Failed to create page", failedCreateWorkspace: "Failed to create workspace", failedDeletePage: "Failed to delete page", failedPermanentDeleteDatabase: "Failed to permanently delete database", + failedPermanentDeletePage: "Failed to permanently delete page", failedMovePage: "Failed to move page", failedUpdateFavorite: "Could not update favorite", removeFromFavorites: "Remove from favorites", failedRemoveLocalFiles: "Failed to remove local files", failedRestoreDatabase: "Failed to restore database", + failedRestorePage: "Failed to restore page", + failedSaveSidebarState: "Failed to save sidebar state", deletePageDescription: - "“{{title}}” and all its sub-pages will be permanently deleted. This cannot be undone.", - deletePageQuestion: "Delete page?", + "“{{title}}” and all its sub-pages will move to Trash. You can restore them later.", + deletePageQuestion: "Move page to Trash?", localFiles: "Local files", localFolder: "Local folder", files: "Files", @@ -3192,10 +3201,13 @@ const enUS = { removeLocalFilesQuestion: "Remove local files from sidebar?", restoreDatabase: "Restore", restoreDatabaseNamed: "Restore {{title}}", + restorePage: "Restore", + restorePageNamed: "Restore {{title}}", saving: "Saving...", sharedCopies: "Shared copies", synced: "Synced", trash: "Trash", + trashEmpty: "Trash is empty", favorites: "Favorites", untitled: "Untitled", workspaces: "Workspaces", @@ -3330,6 +3342,7 @@ const rawLiteralLocaleMessages: Partial> = { failedRemoveLocalFiles: "移除本地文件失败", failedPermanentDeleteDatabase: "永久删除数据库失败", failedRestoreDatabase: "恢复数据库失败", + failedSaveSidebarState: "保存侧边栏状态失败", localFilesActions: "本地文件操作", localFilesRemoved: "本地文件已移除", localFilesRemovedDescription: "已移除 {{count}} 个项目", @@ -3629,6 +3642,8 @@ const rawLiteralLocaleMessages: Partial> = { failedPermanentDeleteDatabase: "No se pudo eliminar permanentemente la base de datos", failedRestoreDatabase: "No se pudo restaurar la base de datos", + failedSaveSidebarState: + "No se pudo guardar el estado de la barra lateral", localFilesActions: "Acciones de archivos locales", localFilesRemoved: "Archivos locales quitados", localFilesRemovedDescription: "{{count}} elementos quitados", @@ -3692,6 +3707,7 @@ const rawLiteralLocaleMessages: Partial> = { failedRemoveLocalFiles: "移除本機檔案失敗", failedPermanentDeleteDatabase: "永久刪除資料庫失敗", failedRestoreDatabase: "還原資料庫失敗", + failedSaveSidebarState: "儲存側邊欄狀態失敗", localFilesActions: "本機檔案操作", localFilesRemoved: "已移除本機檔案", localFilesRemovedDescription: "已移除 {{count}} 個項目", @@ -3725,6 +3741,8 @@ const rawLiteralLocaleMessages: Partial> = { failedPermanentDeleteDatabase: "Échec de la suppression définitive de la base de données", failedRestoreDatabase: "Échec de la restauration de la base de données", + failedSaveSidebarState: + "Échec de l’enregistrement de l’état de la barre latérale", localFilesActions: "Actions des fichiers locaux", localFilesRemoved: "Fichiers locaux supprimés", localFilesRemovedDescription: "{{count}} éléments supprimés", @@ -3758,6 +3776,8 @@ const rawLiteralLocaleMessages: Partial> = { failedPermanentDeleteDatabase: "Datenbank konnte nicht endgültig gelöscht werden", failedRestoreDatabase: "Datenbank konnte nicht wiederhergestellt werden", + failedSaveSidebarState: + "Der Seitenleistenstatus konnte nicht gespeichert werden", localFilesActions: "Aktionen für lokale Dateien", localFilesRemoved: "Lokale Dateien entfernt", localFilesRemovedDescription: "{{count}} Elemente entfernt", @@ -3790,6 +3810,7 @@ const rawLiteralLocaleMessages: Partial> = { failedRemoveLocalFiles: "ローカルファイルを削除できませんでした", failedPermanentDeleteDatabase: "データベースを完全に削除できませんでした", failedRestoreDatabase: "データベースを復元できませんでした", + failedSaveSidebarState: "サイドバーの状態を保存できませんでした", localFilesActions: "ローカルファイルの操作", localFilesRemoved: "ローカルファイルを削除しました", localFilesRemovedDescription: "{{count}} 件を削除しました", @@ -3822,6 +3843,7 @@ const rawLiteralLocaleMessages: Partial> = { failedRemoveLocalFiles: "로컬 파일을 제거하지 못했습니다", failedPermanentDeleteDatabase: "데이터베이스를 영구 삭제하지 못했습니다", failedRestoreDatabase: "데이터베이스를 복원하지 못했습니다", + failedSaveSidebarState: "사이드바 상태를 저장하지 못했습니다", localFilesActions: "로컬 파일 작업", localFilesRemoved: "로컬 파일이 제거되었습니다", localFilesRemovedDescription: "{{count}}개 항목 제거됨", @@ -3855,6 +3877,7 @@ const rawLiteralLocaleMessages: Partial> = { failedPermanentDeleteDatabase: "Falha ao excluir banco de dados permanentemente", failedRestoreDatabase: "Falha ao restaurar banco de dados", + failedSaveSidebarState: "Falha ao salvar o estado da barra lateral", localFilesActions: "Ações de arquivos locais", localFilesRemoved: "Arquivos locais removidos", localFilesRemovedDescription: "{{count}} itens removidos", @@ -3886,6 +3909,7 @@ const rawLiteralLocaleMessages: Partial> = { failedRemoveLocalFiles: "स्थानीय फ़ाइलें हटाई नहीं जा सकीं", failedPermanentDeleteDatabase: "डेटाबेस स्थायी रूप से हटाया नहीं जा सका", failedRestoreDatabase: "डेटाबेस बहाल नहीं हो सका", + failedSaveSidebarState: "साइडबार की स्थिति सहेजी नहीं जा सकी", localFilesActions: "स्थानीय फ़ाइल क्रियाएं", localFilesRemoved: "स्थानीय फ़ाइलें हटाई गईं", localFilesRemovedDescription: "{{count}} आइटम हटाए गए", @@ -3926,6 +3950,7 @@ const rawLiteralLocaleMessages: Partial> = { "هل تريد إزالة الملفات المحلية من الشريط الجانبي؟", failedPermanentDeleteDatabase: "فشل حذف قاعدة البيانات نهائيًا", failedRestoreDatabase: "فشلت استعادة قاعدة البيانات", + failedSaveSidebarState: "فشل حفظ حالة الشريط الجانبي", new: "جديد", page: "صفحة", restoreDatabase: "استعادة", @@ -8915,6 +8940,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "无法重新排序页面", + deletePagePermanentlyQuestion: "永久删除页面?", loadingFiles: "正在加载文件…", newDatabase: "新建数据库", noWorkspaces: "还没有工作区", @@ -8922,6 +8948,8 @@ export const messagesByLocale = { expand: "展开侧边栏", failedCreatePage: "创建页面失败", failedDeletePage: "删除页面失败", + failedPermanentDeletePage: "永久删除页面失败", + failedRestorePage: "恢复页面失败", failedMovePage: "移动页面失败", localFiles: "本地文件", newPage: "新页面", @@ -8933,11 +8961,14 @@ export const messagesByLocale = { noSharedCopiesYet: "还没有共享副本", oneAffectedPageReadOnly: "受影响的页面之一是只读的。", organization: "组织", + pagePermanentlyDeleted: "页面已永久删除", + pageRestored: "页面已恢复", private: "私有", results: "结果", search: "搜索", searchPages: "搜索页面...", sharedCopies: "共享副本", + trashEmpty: "回收站为空", favorites: "收藏", untitled: "无标题", }, @@ -9105,6 +9136,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "No se pueden reordenar las páginas", + deletePagePermanentlyQuestion: "¿Eliminar la página permanentemente?", loadingFiles: "Cargando archivos…", newDatabase: "Nueva base de datos", noWorkspaces: "Aún no hay espacios de trabajo", @@ -9112,6 +9144,9 @@ export const messagesByLocale = { expand: "Expandir barra lateral", failedCreatePage: "No se pudo crear la página", failedDeletePage: "No se pudo eliminar la página", + failedPermanentDeletePage: + "No se pudo eliminar la página permanentemente", + failedRestorePage: "No se pudo restaurar la página", failedMovePage: "No se pudo mover la página", localFiles: "Archivos locales", newPage: "Nueva página", @@ -9124,11 +9159,14 @@ export const messagesByLocale = { oneAffectedPageReadOnly: "Una de las páginas afectadas es de solo lectura.", organization: "Organización", + pagePermanentlyDeleted: "Página eliminada permanentemente", + pageRestored: "Página restaurada", private: "Privado", results: "Resultados", search: "Buscar", searchPages: "Buscar páginas...", sharedCopies: "Copias compartidas", + trashEmpty: "La papelera está vacía", favorites: "Favoritos", untitled: "Sin título", }, @@ -9296,6 +9334,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "Impossible de réordonner les pages", + deletePagePermanentlyQuestion: "Supprimer définitivement la page ?", loadingFiles: "Chargement des fichiers…", newDatabase: "Nouvelle base de données", noWorkspaces: "Aucun espace de travail pour le moment", @@ -9303,6 +9342,9 @@ export const messagesByLocale = { expand: "Développer la barre latérale", failedCreatePage: "Échec de la création de la page", failedDeletePage: "Échec de la suppression de la page", + failedPermanentDeletePage: + "Échec de la suppression définitive de la page", + failedRestorePage: "Échec de la restauration de la page", failedMovePage: "Échec du déplacement de la page", localFiles: "Fichiers locaux", newPage: "Nouvelle page", @@ -9315,11 +9357,14 @@ export const messagesByLocale = { oneAffectedPageReadOnly: "L'une des pages concernées est en lecture seule.", organization: "Organisation", + pagePermanentlyDeleted: "Page supprimée définitivement", + pageRestored: "Page restaurée", private: "Privé", results: "Résultats", search: "Rechercher", searchPages: "Rechercher des pages...", sharedCopies: "Copies partagées", + trashEmpty: "La corbeille est vide", favorites: "Favoris", untitled: "Sans titre", }, @@ -9485,6 +9530,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "Seiten können nicht neu angeordnet werden", + deletePagePermanentlyQuestion: "Seite dauerhaft löschen?", loadingFiles: "Dateien werden geladen…", newDatabase: "Neue Datenbank", noWorkspaces: "Noch keine Arbeitsbereiche", @@ -9492,6 +9538,8 @@ export const messagesByLocale = { expand: "Seitenleiste ausklappen", failedCreatePage: "Seite konnte nicht erstellt werden", failedDeletePage: "Seite konnte nicht gelöscht werden", + failedPermanentDeletePage: "Seite konnte nicht dauerhaft gelöscht werden", + failedRestorePage: "Seite konnte nicht wiederhergestellt werden", failedMovePage: "Seite konnte nicht verschoben werden", localFiles: "Lokale Dateien", newPage: "Neue Seite", @@ -9504,11 +9552,14 @@ export const messagesByLocale = { oneAffectedPageReadOnly: "Eine der betroffenen Seiten ist schreibgeschützt.", organization: "Organisation", + pagePermanentlyDeleted: "Seite dauerhaft gelöscht", + pageRestored: "Seite wiederhergestellt", private: "Privat", results: "Ergebnisse", search: "Suchen", searchPages: "Seiten suchen...", sharedCopies: "Geteilte Kopien", + trashEmpty: "Papierkorb ist leer", favorites: "Favoriten", untitled: "Ohne Titel", }, @@ -9674,6 +9725,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "ページを並べ替えられません", + deletePagePermanentlyQuestion: "ページを完全に削除しますか?", loadingFiles: "ファイルを読み込んでいます…", newDatabase: "新しいデータベース", noWorkspaces: "ワークスペースはまだありません", @@ -9681,6 +9733,8 @@ export const messagesByLocale = { expand: "サイドバーを展開", failedCreatePage: "ページを作成できませんでした", failedDeletePage: "ページを削除できませんでした", + failedPermanentDeletePage: "ページを完全に削除できませんでした", + failedRestorePage: "ページを復元できませんでした", failedMovePage: "ページを移動できませんでした", localFiles: "ローカルファイル", newPage: "新しいページ", @@ -9692,11 +9746,14 @@ export const messagesByLocale = { noSharedCopiesYet: "まだ共有コピーはありません", oneAffectedPageReadOnly: "影響を受けるページの 1 つは読み取り専用です。", organization: "組織", + pagePermanentlyDeleted: "ページを完全に削除しました", + pageRestored: "ページを復元しました", private: "非公開", results: "結果", search: "検索", searchPages: "ページを検索...", sharedCopies: "共有コピー", + trashEmpty: "ゴミ箱は空です", favorites: "お気に入り", untitled: "無題", }, @@ -9852,6 +9909,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "페이지 순서를 변경할 수 없습니다", + deletePagePermanentlyQuestion: "페이지를 영구적으로 삭제하시겠습니까?", loadingFiles: "파일을 불러오는 중…", newDatabase: "새 데이터베이스", noWorkspaces: "아직 워크스페이스가 없습니다", @@ -9859,6 +9917,8 @@ export const messagesByLocale = { expand: "사이드바 펼치기", failedCreatePage: "페이지를 만들지 못했습니다", failedDeletePage: "페이지를 삭제하지 못했습니다", + failedPermanentDeletePage: "페이지를 영구적으로 삭제하지 못했습니다", + failedRestorePage: "페이지를 복원하지 못했습니다", failedMovePage: "페이지를 이동하지 못했습니다", localFiles: "로컬 파일", newPage: "새 페이지", @@ -9870,11 +9930,14 @@ export const messagesByLocale = { noSharedCopiesYet: "아직 공유 사본이 없습니다", oneAffectedPageReadOnly: "영향을 받는 페이지 중 하나가 읽기 전용입니다.", organization: "조직", + pagePermanentlyDeleted: "페이지가 영구적으로 삭제되었습니다", + pageRestored: "페이지가 복원되었습니다", private: "비공개", results: "결과", search: "검색", searchPages: "페이지 검색...", sharedCopies: "공유 사본", + trashEmpty: "휴지통이 비어 있습니다", favorites: "즐겨찾기", untitled: "제목 없음", }, @@ -10042,6 +10105,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "Não é possível reordenar páginas", + deletePagePermanentlyQuestion: "Excluir a página permanentemente?", loadingFiles: "Carregando arquivos…", newDatabase: "Novo banco de dados", noWorkspaces: "Ainda não há espaços de trabalho", @@ -10049,6 +10113,8 @@ export const messagesByLocale = { expand: "Expandir barra lateral", failedCreatePage: "Falha ao criar página", failedDeletePage: "Falha ao excluir página", + failedPermanentDeletePage: "Falha ao excluir a página permanentemente", + failedRestorePage: "Falha ao restaurar a página", failedMovePage: "Falha ao mover página", localFiles: "Arquivos locais", newPage: "Nova página", @@ -10060,11 +10126,14 @@ export const messagesByLocale = { noSharedCopiesYet: "Ainda não há cópias compartilhadas", oneAffectedPageReadOnly: "Uma das páginas afetadas é somente leitura.", organization: "Organização", + pagePermanentlyDeleted: "Página excluída permanentemente", + pageRestored: "Página restaurada", private: "Privado", results: "Resultados", search: "Buscar", searchPages: "Buscar páginas...", sharedCopies: "Cópias compartilhadas", + trashEmpty: "A lixeira está vazia", favorites: "Favoritos", untitled: "Sem título", }, @@ -10218,6 +10287,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "पेजों को फिर से क्रमबद्ध नहीं किया जा सकता", + deletePagePermanentlyQuestion: "पेज को स्थायी रूप से हटाएं?", loadingFiles: "फ़ाइलें लोड हो रही हैं…", newDatabase: "नया डेटाबेस", noWorkspaces: "अभी कोई कार्यस्थान नहीं है", @@ -10225,6 +10295,8 @@ export const messagesByLocale = { expand: "साइडबार फैलाएं", failedCreatePage: "पेज नहीं बन सका", failedDeletePage: "पेज हटाया नहीं जा सका", + failedPermanentDeletePage: "पेज को स्थायी रूप से हटाया नहीं जा सका", + failedRestorePage: "पेज पुनर्स्थापित नहीं किया जा सका", failedMovePage: "पेज स्थानांतरित नहीं हो सका", localFiles: "स्थानीय फ़ाइलें", newPage: "नया पेज", @@ -10236,11 +10308,14 @@ export const messagesByLocale = { noSharedCopiesYet: "अभी कोई साझा कॉपी नहीं है", oneAffectedPageReadOnly: "प्रभावित पेजों में से एक केवल-पढ़ने योग्य है।", organization: "संगठन", + pagePermanentlyDeleted: "पेज स्थायी रूप से हटा दिया गया", + pageRestored: "पेज पुनर्स्थापित किया गया", private: "निजी", results: "परिणाम", search: "खोजें", searchPages: "पेज खोजें...", sharedCopies: "साझा कॉपियां", + trashEmpty: "ट्रैश खाली है", favorites: "पसंदीदा", untitled: "शीर्षकहीन", }, @@ -10397,6 +10472,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "تعذرت إعادة ترتيب الصفحات", + deletePagePermanentlyQuestion: "هل تريد حذف الصفحة نهائيًا؟", loadingFiles: "جارٍ تحميل الملفات…", newDatabase: "قاعدة بيانات جديدة", noWorkspaces: "لا توجد مساحات عمل بعد", @@ -10404,6 +10480,8 @@ export const messagesByLocale = { expand: "توسيع الشريط الجانبي", failedCreatePage: "فشل إنشاء الصفحة", failedDeletePage: "فشل حذف الصفحة", + failedPermanentDeletePage: "فشل حذف الصفحة نهائيًا", + failedRestorePage: "فشل استعادة الصفحة", failedMovePage: "فشل نقل الصفحة", localFiles: "الملفات المحلية", newPage: "صفحة جديدة", @@ -10415,11 +10493,14 @@ export const messagesByLocale = { noSharedCopiesYet: "لا توجد نسخ مشتركة بعد", oneAffectedPageReadOnly: "إحدى الصفحات المتأثرة للقراءة فقط.", organization: "المؤسسة", + pagePermanentlyDeleted: "تم حذف الصفحة نهائيًا", + pageRestored: "تمت استعادة الصفحة", private: "خاص", results: "النتائج", search: "بحث", searchPages: "البحث في الصفحات...", sharedCopies: "النسخ المشتركة", + trashEmpty: "سلة المهملات فارغة", favorites: "المفضلة", untitled: "بدون عنوان", }, diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index ed37c37a89..f5323d7942 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1102,10 +1102,16 @@ const messages = { database: "資料庫", databasePermanentlyDeleted: "資料庫已永久刪除", databaseRestored: "資料庫已還原", + pagePermanentlyDeleted: "頁面已永久刪除", + pageRestored: "頁面已還原", deleteDatabaseNamedPermanently: "永久刪除 {{title}}", deleteDatabasePermanentlyDescription: "這會永久刪除「{{title}}」及其頁面。此操作無法復原。", deleteDatabasePermanentlyQuestion: "永久刪除資料庫?", + deletePageNamedPermanently: "永久刪除 {{title}}", + deletePagePermanentlyDescription: + "這會永久刪除「{{title}}」及其子頁面。此操作無法復原。", + deletePagePermanentlyQuestion: "永久刪除頁面?", deletePermanently: "永久刪除", failedCreateDatabase: "建立資料庫失敗", failedCreateWorkspace: "無法建立工作區", @@ -1123,8 +1129,11 @@ const messages = { failedCreatePage: "建立頁面失敗", failedDeletePage: "刪除頁面失敗", failedPermanentDeleteDatabase: "永久刪除資料庫失敗", + failedPermanentDeletePage: "永久刪除頁面失敗", failedMovePage: "行動頁面失敗", failedRestoreDatabase: "還原資料庫失敗", + failedRestorePage: "還原頁面失敗", + failedSaveSidebarState: "儲存側邊欄狀態失敗", deletePageDescription: "「{{title}}」及其所有子頁面將被永久刪除。此操作無法復原。", deletePageQuestion: "刪除頁面?", @@ -1182,10 +1191,13 @@ const messages = { refreshConnection: "重新整理連線", restoreDatabase: "還原", restoreDatabaseNamed: "還原 {{title}}", + restorePage: "還原", + restorePageNamed: "還原 {{title}}", saving: "正在儲存...", sharedCopies: "共用副本", synced: "已同步", trash: "垃圾桶", + trashEmpty: "垃圾桶是空的", favorites: "釘選", untitled: "無標題", workspaces: "工作區", diff --git a/templates/content/app/routes/_app.local-files.tsx b/templates/content/app/routes/_app.local-files.tsx index 5698e7133b..3edbd34c26 100644 --- a/templates/content/app/routes/_app.local-files.tsx +++ b/templates/content/app/routes/_app.local-files.tsx @@ -17,7 +17,7 @@ import { } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router"; +import { useLocation, useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -749,11 +749,17 @@ export default function LocalFilesRoute() { const t = useT(); const queryClient = useQueryClient(); const [searchParams] = useSearchParams(); + const location = useLocation(); const navigate = useNavigate(); const targetSpaceId = searchParams.get("spaceId") || undefined; const targetDatabaseId = searchParams.get("databaseId") || undefined; const manifestConnectionId = searchParams.get("connectionId") || undefined; const manifestFile = searchParams.get("file") || undefined; + const workspacePropertyValues = ( + location.state as { + workspacePropertyValues?: Record; + } | null + )?.workspacePropertyValues; const { data: documents = [] } = useDocuments(); const [directories, setDirectories] = useState([]); const [status, setStatus] = useState({ kind: "idle" }); @@ -929,6 +935,7 @@ export default function LocalFilesRoute() { spaceId: targetSpaceId, databaseId: targetDatabaseId, createSourceBackedSpace: !targetSpaceId && !targetDatabaseId, + propertyValues: workspacePropertyValues, truthPolicy: "source_primary", dryRun, } as never, diff --git a/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md b/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md new file mode 100644 index 0000000000..df0d7fa19f --- /dev/null +++ b/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-19 +--- + +Pages now move to a reversible Trash, the organization picker is restored, and sidebar disclosure icons behave consistently. diff --git a/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md b/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md new file mode 100644 index 0000000000..d1445f0684 --- /dev/null +++ b/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-20 +--- + +Trash actions now preserve independent archived pages, enforce access, and restore database rows in order diff --git a/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md b/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md new file mode 100644 index 0000000000..54386cf598 --- /dev/null +++ b/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-20 +--- + +Workspace toggles now stay independently open or closed, and nested pages remain grouped beneath their parents. diff --git a/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md b/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md new file mode 100644 index 0000000000..622d61564d --- /dev/null +++ b/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-20 +--- + +Workspaces can be added from a blank workspace or a connected local folder from any creation menu. diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index 321812dd03..5038b525c8 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -20,7 +20,7 @@ This generated matrix tracks whether high-value Content UI operations use the sa | sharing.document-discoverability-and-export | sharing | Share, hide from search, export, and reveal documents | action-backed | `export-document`, `reveal-local-source-file`, `set-document-discoverability`, `share-local-file-document` | `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Search discoverability, shareable copies, exports, and OS reveal requests are managed through Content actions. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | | sharing.os-reveal-local-source | sharing | Reveal a local source file in the system file manager | host-only | `reveal-local-source-file` | `app/components/editor/DocumentToolbar.tsx`, `actions/reveal-local-source-file.ts` | - | OS reveal depends on trusted local host capabilities and should not spend agent tool surface or imply portable hosted behavior. | - | P2 | seeded | - | - | Local folder exception/docs PR | | sidebar.chrome-state | sidebar | Collapse sections and resize the sidebar | client-only-ephemeral | - | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/layout/Layout.tsx` | - | - | - | P2 | none | - | - | - | -| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `clone-creative-context-document`, `delete-document`, `get-document`, `list-documents`, `move-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | +| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `clone-creative-context-document`, `delete-document`, `get-document`, `list-trashed-documents`, `list-documents`, `move-document`, `permanently-delete-document`, `restore-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | | sidebar.navigation-and-screen-context | sidebar | Navigate between documents and expose current screen context | action-equivalent | `navigate`, `view-screen` | `app/components/sidebar/DocumentSidebar.tsx`, `actions/navigate.ts`, `actions/view-screen.ts` | Application navigation state is updated or read so the agent can reason about the user's current page/view. | Human navigation is router-local, while agent navigation/screen inspection uses application-state actions to produce the same workspace orientation effect. | - | P1 | seeded | - | - | - | | source-sync.builder-body-hydration-worker | source-sync | Process queued Builder CMS body hydration work | action-backed | `process-builder-body-hydration` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Queued Builder body hydration entries are processed into readable Content document/database body state. | This action is intentionally hidden from the model with agentTool: false because it is an internal bounded queue worker; agents should use source refresh, review, and execution actions rather than manually driving hydration internals. | - | P0 | covered | `actions/_database-source-utils.test.ts` | - | - | | source-sync.builder-cms-review-and-write-gates | source-sync | Review, stage, validate, cancel, and execute Builder CMS source writes | action-backed | `cancel-prepared-builder-source-update`, `execute-builder-source-batch`, `execute-builder-source-execution`, `prepare-builder-source-execution`, `prepare-builder-source-review`, `preview-builder-source-review`, `review-content-database-source-change-set`, `set-content-database-source-write-mode`, `stage-builder-source-bulk-update`, `stage-builder-revision`, `validate-builder-source-execution` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/editor/database-sources/BuilderSourceReviewDialog.tsx` | Builder source write mode, staged reviews, pre-dispatch cancellations, validation records, and bounded execution records are created through guarded actions. | - | - | P0 | covered | `actions/builder-source-review-gates.db.test.ts`, `actions/cancel-prepared-builder-source-update.db.test.ts`, `actions/execute-builder-source-execution.test.ts`, `actions/stage-builder-source-bulk-update.db.test.ts` | `builder-source-review-readonly` | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index 8423933bf0..5c76b031b2 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -20,8 +20,11 @@ export const parityMatrix: ParityRow[] = [ "clone-creative-context-document", "delete-document", "get-document", + "list-trashed-documents", "list-documents", "move-document", + "permanently-delete-document", + "restore-document", "search-documents", "update-document", ], diff --git a/templates/content/server/__tests__/db.spec.ts b/templates/content/server/__tests__/db.spec.ts index 30d5c206fb..218c6450f8 100644 --- a/templates/content/server/__tests__/db.spec.ts +++ b/templates/content/server/__tests__/db.spec.ts @@ -91,6 +91,23 @@ describe("content database migrations", () => { ); }); + it("adds the document trash lifecycle additively", () => { + const source = readFileSync( + join(__dirname, "..", "plugins", "db.ts"), + "utf8", + ); + + expect(source).toContain( + "ALTER TABLE documents ADD COLUMN IF NOT EXISTS trashed_at TEXT", + ); + expect(source).toContain( + "ALTER TABLE documents ADD COLUMN IF NOT EXISTS trash_root_id TEXT", + ); + expect(source).toContain( + "documents_trash_idx ON documents (owner_email, trashed_at, trash_root_id)", + ); + }); + it("creates Builder MDX sidecar cache table additively", () => { const source = readFileSync( join(__dirname, "..", "plugins", "db.ts"), diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index 6b8d894d61..0f32c9d1af 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -27,6 +27,8 @@ export const documents = table("documents", { sourcePath: text("source_path"), sourceRootPath: text("source_root_path"), sourceUpdatedAt: text("source_updated_at"), + trashedAt: text("trashed_at"), + trashRootId: text("trash_root_id"), createdAt: text("created_at").notNull().default(now()), updatedAt: text("updated_at").notNull().default(now()), ...ownableColumns(), diff --git a/templates/content/server/lib/public-documents.ts b/templates/content/server/lib/public-documents.ts index 2c72eca723..c81d07703b 100644 --- a/templates/content/server/lib/public-documents.ts +++ b/templates/content/server/lib/public-documents.ts @@ -88,7 +88,7 @@ async function getPublicDocumentForEvent(event: H3Event) { const { getDb } = await import("../db/index.js"); const { documents } = await import("../db/schema.js"); - const { and, eq } = await import("drizzle-orm"); + const { and, eq, isNull } = await import("drizzle-orm"); const [doc] = await getDb() .select({ @@ -100,7 +100,13 @@ async function getPublicDocumentForEvent(event: H3Event) { visibility: documents.visibility, }) .from(documents) - .where(and(eq(documents.id, id), eq(documents.visibility, "public"))) + .where( + and( + eq(documents.id, id), + eq(documents.visibility, "public"), + isNull(documents.trashedAt), + ), + ) .limit(1); return doc ?? null; diff --git a/templates/content/server/plugins/db.spec.ts b/templates/content/server/plugins/db.spec.ts index eb0cd9b26b..e03e44925c 100644 --- a/templates/content/server/plugins/db.spec.ts +++ b/templates/content/server/plugins/db.spec.ts @@ -192,4 +192,14 @@ describe("content db.ts wires ensureAdditiveColumns after runMigrations", () => /CREATE INDEX IF NOT EXISTS content_database_items_body_hydration_idx ON content_database_items \(database_id, body_hydration_status\)/, ); }); + + it("backfills legacy deleted database trees into explicit document Trash roots", () => { + expect(dbTsSource).toContain('name: "backfill-database-trash-roots"'); + expect(dbTsSource).toMatch( + /WITH RECURSIVE legacy_database_trash[\s\S]*?child\.parent_id = legacy_database_trash\.document_id[\s\S]*?trash_root_id = \([\s\S]*?legacy_database_trash\.root_id/, + ); + expect(dbTsSource).toMatch( + /child_database\.document_id = child\.id[\s\S]*?child_database\.deleted_at IS NOT NULL/, + ); + }); }); diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 375b76d7e8..016572d4fd 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -856,6 +856,58 @@ const runContentMigrations = runMigrations( CREATE UNIQUE INDEX IF NOT EXISTS document_property_definitions_database_system_role_unique ON document_property_definitions (database_id, system_role)`, }, + { + version: 76, + name: "document-trash-lifecycle", + sql: `ALTER TABLE documents ADD COLUMN IF NOT EXISTS trashed_at TEXT; + ALTER TABLE documents ADD COLUMN IF NOT EXISTS trash_root_id TEXT; + CREATE INDEX IF NOT EXISTS documents_trash_idx ON documents (owner_email, trashed_at, trash_root_id)`, + }, + { + version: 77, + name: "backfill-database-trash-roots", + // guard:allow-unscoped — boot migration claims only archived database trees that predate document Trash metadata. + sql: `WITH RECURSIVE legacy_database_trash(document_id, root_id, deleted_at) AS ( + SELECT documents.id, documents.id, MIN(content_databases.deleted_at) + FROM documents + INNER JOIN content_databases + ON content_databases.document_id = documents.id + WHERE documents.trashed_at IS NULL + AND content_databases.deleted_at IS NOT NULL + GROUP BY documents.id + UNION + SELECT child.id, legacy_database_trash.root_id, legacy_database_trash.deleted_at + FROM documents child + INNER JOIN legacy_database_trash + ON child.parent_id = legacy_database_trash.document_id + WHERE child.trashed_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM content_databases child_database + WHERE child_database.document_id = child.id + AND child_database.deleted_at IS NOT NULL + ) + ) + UPDATE documents + SET trashed_at = ( + SELECT legacy_database_trash.deleted_at + FROM legacy_database_trash + WHERE legacy_database_trash.document_id = documents.id + LIMIT 1 + ), + trash_root_id = ( + SELECT legacy_database_trash.root_id + FROM legacy_database_trash + WHERE legacy_database_trash.document_id = documents.id + LIMIT 1 + ) + WHERE documents.trashed_at IS NULL + AND EXISTS ( + SELECT 1 + FROM legacy_database_trash + WHERE legacy_database_trash.document_id = documents.id + )`, + }, ], { table: "content_migrations" }, ); diff --git a/templates/content/server/routes/api/document-agent-context.json.get.test.ts b/templates/content/server/routes/api/document-agent-context.json.get.test.ts index 464bcf7bc8..1589f38321 100644 --- a/templates/content/server/routes/api/document-agent-context.json.get.test.ts +++ b/templates/content/server/routes/api/document-agent-context.json.get.test.ts @@ -23,7 +23,9 @@ vi.mock("@agent-native/core/server", () => ({ })); vi.mock("drizzle-orm", () => ({ + and: (...args: unknown[]) => args, eq: (...args: unknown[]) => args, + isNull: (...args: unknown[]) => args, })); vi.mock("h3", () => ({ diff --git a/templates/content/server/routes/api/document-agent-context.json.get.ts b/templates/content/server/routes/api/document-agent-context.json.get.ts index 56b9f0dfbe..a4d93cfd90 100644 --- a/templates/content/server/routes/api/document-agent-context.json.get.ts +++ b/templates/content/server/routes/api/document-agent-context.json.get.ts @@ -3,7 +3,7 @@ import { getConfiguredAppBasePath, verifyScopedAgentAccessToken, } from "@agent-native/core/server"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { defineEventHandler, getQuery, @@ -56,7 +56,7 @@ export default defineEventHandler(async (event) => { createdAt: schema.documents.createdAt, }) .from(schema.documents) - .where(eq(schema.documents.id, id)) + .where(and(eq(schema.documents.id, id), isNull(schema.documents.trashedAt))) .limit(1); if (!document) { diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index f80f795e47..9a0a31840a 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -911,6 +911,16 @@ export interface ListTrashedContentDatabasesResponse { databases: TrashedContentDatabaseSummary[]; } +export interface TrashedDocumentSummary { + documentId: string; + title: string; + trashedAt: string; +} + +export interface ListTrashedDocumentsResponse { + documents: TrashedDocumentSummary[]; +} + export interface SuggestSourceJoinKeyRequest { databaseId?: string; documentId?: string;