diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 56eec8fe07d..1873914a4c1 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -51,16 +51,14 @@ Read the relevant skill before deeper work: and respect `contextMode: "off"` without silently restoring a pack. - `content-last-location-v1` — the last successfully loaded Page. The UI and landing resolver own this state; do not write it from agent workflows. -- `content-trash` — bounded Trash filters, up to 100 selected Page IDs, the - preview Page ID, and the current purge operation ID. Treat selection and - filters as UI context, not deletion authority; plan the exact scope before - permanent deletion. +- `content-trash` — Trash filters, selected/preview Page IDs, and purge operation + ID. This is UI context, not deletion authority. - Use actions for full document content and comment context. ## Actions | Action | Purpose | -| --- | --- | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `view-screen` | Re-read the current screen when `` is stale | | `navigate` | Move the UI to a document, comments, media, or settings | | `refresh-list` | Repaint the sidebar after an out-of-band mutation | @@ -70,7 +68,8 @@ Read the relevant skill before deeper work: | `pull-document` | Flush live collab state, then read (external edits) | | `get-blocks-field-word-count` | Count one exact Blocks field; omit `propertyId` for the primary Content body | | `create-document` | Create a page, optionally under a parent | -| `resolve-content-landing` | Restore the caller's last authorized page | +| `resolve-content-landing` | Restore the caller's last authorized page in a requested Content space | +| `get-content-recent` | List personal recent destinations with current access, optionally scoped to a Content space's Files membership | | `edit-document` | Revisioned find/replace, or initialize an empty body | | `update-document` | Metadata or browser-owned full rewrite | | `delete-document` | Move a page and its children to Trash | @@ -88,12 +87,12 @@ Every action carries its schema. Use `tool-search` for comments, sharing, Collections, Notion, local sources such as `remove-local-file-source`, and the rest of the registered surface. -For permanent deletion, always plan before executing and report blockers or -conflicts rather than claiming Trash is empty. Scope mode means all manageable -Trash in the chosen space scope and intentionally ignores text, kind, actor, -and location filters. A selected or matching Page includes only itself and its -currently trashed descendants, never its Trash root or siblings. New or changed -Trash after planning is not silently added. +Permanent deletion requires a frozen plan. Read `document-editing` and report +blockers or conflicts instead of claiming Trash is empty. + +Sidebar order and active Views use the personal view `navigation` patch, never +parentage. Recent means foreground visits; paging, active-path lookup, and visit +recording are UI-owned. ## Source Changes diff --git a/templates/content/actions/_content-database-personal-view.ts b/templates/content/actions/_content-database-personal-view.ts index 9ca1b260e07..35f026a79f6 100644 --- a/templates/content/actions/_content-database-personal-view.ts +++ b/templates/content/actions/_content-database-personal-view.ts @@ -42,9 +42,10 @@ export const filterSchema = z.object({ parentFilterGroupId: z.string().optional(), }); +const sidebarOrderItemIdSchema = z.string().min(1).max(256); export const sidebarOrderSchema = z.object({ mode: z.enum(["custom", "last_edited", "name", "created"]), - itemIds: z.array(z.string()), + itemIds: z.array(sidebarOrderItemIdSchema).max(5_000), }); const personalViewOverridesFields = { @@ -123,24 +124,41 @@ export async function readPersonalDatabaseViewOverrides( userEmail, personalDatabaseViewSettingKey(databaseId), ); + if (stored === null) return null; const parsed = personalViewOverridesSchema.safeParse(stored); if (parsed.success) return normalizePersonalDatabaseViewOverrides(parsed.data); - const legacy = legacyPersonalViewOverridesSchema.safeParse(stored); - if (!legacy.success) return null; + legacyPersonalViewOverridesSchema.parse(stored); const [database] = await getDb() .select({ systemRole: schema.contentDatabases.systemRole }) .from(schema.contentDatabases) .where(eq(schema.contentDatabases.id, databaseId)); + return migratePersonalDatabaseViewOverrides( + stored, + databaseId, + database?.systemRole ?? null, + ); +} + +export function migratePersonalDatabaseViewOverrides( + stored: unknown, + databaseId: string, + systemRole: string | null, +) { + if (stored === null) return null; + const parsed = personalViewOverridesSchema.safeParse(stored); + if (parsed.success) + return normalizePersonalDatabaseViewOverrides(parsed.data); + const legacy = legacyPersonalViewOverridesSchema.parse(stored); const legacyParentKey = filesParentPropertyId(databaseId); return normalizePersonalDatabaseViewOverrides({ - ...legacy.data, + ...legacy, version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, - views: legacy.data.views.map((view) => ({ + views: legacy.views.map((view) => ({ ...view, filters: - database?.systemRole === "files" + systemRole === "files" ? view.filters.filter( (filter) => !( diff --git a/templates/content/actions/_content-favorites.ts b/templates/content/actions/_content-favorites.ts index a4c0b5f6ae1..397c741bb0b 100644 --- a/templates/content/actions/_content-favorites.ts +++ b/templates/content/actions/_content-favorites.ts @@ -19,6 +19,14 @@ export function favoritesSystemIds(userEmail: string) { ); } +export function favoriteMembershipId(userEmail: string, documentId: string) { + const favoritesDatabaseId = favoritesSystemIds(userEmail).databaseId; + return `content_database_item_${createHash("sha256") + .update(`${favoritesDatabaseId}:${documentId}`) + .digest("hex") + .slice(0, 32)}`; +} + export async function favoriteDocumentIds( db: Db, userEmail: string, @@ -48,7 +56,12 @@ export async function setFavoriteMembership(args: { const email = normalizeContentSpaceEmail(args.userEmail); const favoritesDatabaseId = favoritesSystemIds(email).databaseId; const [existing] = await args.db - .select({ id: schema.contentDatabaseItems.id }) + .select({ + id: schema.contentDatabaseItems.id, + position: schema.contentDatabaseItems.position, + createdAt: schema.contentDatabaseItems.createdAt, + updatedAt: schema.contentDatabaseItems.updatedAt, + }) .from(schema.contentDatabaseItems) .where( and( @@ -63,19 +76,28 @@ export async function setFavoriteMembership(args: { .delete(schema.contentDatabaseItems) .where(eq(schema.contentDatabaseItems.id, existing.id)); } - return false; + return { + favorite: false, + changed: Boolean(existing), + membershipId: + existing?.id ?? favoriteMembershipId(email, args.documentId), + previous: existing ?? null, + }; } - if (existing) return true; + if (existing) + return { + favorite: true, + changed: false, + membershipId: existing.id, + previous: existing, + }; const [position] = await args.db .select({ max: sql`COALESCE(MAX(position), -1)` }) .from(schema.contentDatabaseItems) .where(eq(schema.contentDatabaseItems.databaseId, favoritesDatabaseId)); - const id = `content_database_item_${createHash("sha256") - .update(`${favoritesDatabaseId}:${args.documentId}`) - .digest("hex") - .slice(0, 32)}`; - await args.db + const id = favoriteMembershipId(email, args.documentId); + const inserted = await args.db .insert(schema.contentDatabaseItems) .values({ id, @@ -87,6 +109,12 @@ export async function setFavoriteMembership(args: { createdAt: args.now, updatedAt: args.now, }) - .onConflictDoNothing(); - return true; + .onConflictDoNothing() + .returning({ id: schema.contentDatabaseItems.id }); + return { + favorite: true, + changed: inserted.length > 0, + membershipId: id, + previous: null, + }; } diff --git a/templates/content/actions/_content-recent.ts b/templates/content/actions/_content-recent.ts new file mode 100644 index 00000000000..ba4af5393cb --- /dev/null +++ b/templates/content/actions/_content-recent.ts @@ -0,0 +1,194 @@ +import { getRequestOrgId } from "@agent-native/core/server/request-context"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { + contentRecentTargetKey, + type ContentRecentEntry, + type ContentRecentResult, +} from "../shared/content-personal-navigation.js"; +import { readPersonalDatabaseViewOverrides } from "./_content-database-personal-view.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { documentDiscoveryWhere } from "./_document-discovery-query.js"; +import { parseDatabaseViewConfig } from "./_property-utils.js"; + +export function contentRecentSettingKey() { + return `content-recent:${JSON.stringify(getRequestOrgId() ?? null)}`; +} + +const viewIdentitySchema = z.object({ + views: z + .array(z.object({ id: z.string().trim().min(1), name: z.string() })) + .optional(), +}); + +export async function resolveContentRecentEntries( + userEmail: string, + entries: ContentRecentEntry[], + spaceId?: string, +): Promise { + if (entries.length === 0) return []; + const db = getDb(); + const orgId = getRequestOrgId(); + let scopedEntries = entries; + if (spaceId) { + const access = await resolveContentSpaceAccess(spaceId, "viewer", { db }); + if (access.space.orgId && access.space.orgId !== orgId) return []; + const memberships = await db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + access.space.filesDatabaseId, + ), + inArray( + schema.contentDatabaseItems.documentId, + entries.map((entry) => entry.target.documentId), + ), + ), + ); + const memberIds = new Set(memberships.map((row) => row.documentId)); + scopedEntries = entries.filter((entry) => + memberIds.has(entry.target.documentId), + ); + } + if (scopedEntries.length === 0) return []; + const documents = await db + .select({ + id: schema.documents.id, + title: schema.documents.title, + icon: schema.documents.icon, + }) + .from(schema.documents) + .where( + documentDiscoveryWhere({ + userEmail, + authorizedOrgIds: orgId ? [orgId] : [], + additional: inArray( + schema.documents.id, + scopedEntries.map((entry) => entry.target.documentId), + ), + }), + ); + const byId = new Map(documents.map((document) => [document.id, document])); + const databaseEntries = scopedEntries.filter( + (entry) => entry.target.databaseId, + ); + const viewDocumentIds = databaseEntries + .filter( + (entry) => entry.target.databaseId && byId.has(entry.target.documentId), + ) + .map((entry) => entry.target.documentId); + const databases = + viewDocumentIds.length === 0 + ? [] + : await db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + viewConfigJson: schema.contentDatabases.viewConfigJson, + }) + .from(schema.contentDatabases) + .where( + and( + or( + inArray(schema.contentDatabases.documentId, viewDocumentIds), + inArray( + schema.contentDatabases.id, + databaseEntries.map((entry) => entry.target.databaseId!), + ), + ), + isNull(schema.contentDatabases.deletedAt), + ), + ); + const databasesById = new Map( + databases.map((database) => [database.id, database]), + ); + const missingBackingDocumentIds = databases + .map((database) => database.documentId) + .filter((documentId) => !byId.has(documentId)); + if (missingBackingDocumentIds.length > 0) { + const backingDocuments = await db + .select({ + id: schema.documents.id, + title: schema.documents.title, + icon: schema.documents.icon, + }) + .from(schema.documents) + .where( + documentDiscoveryWhere({ + userEmail, + authorizedOrgIds: orgId ? [orgId] : [], + additional: inArray(schema.documents.id, missingBackingDocumentIds), + }), + ); + for (const document of backingDocuments) byId.set(document.id, document); + } + const results: ContentRecentResult[] = []; + const seen = new Set(); + for (const entry of scopedEntries) { + const database = entry.target.databaseId + ? databasesById.get(entry.target.databaseId) + : undefined; + const document = byId.get(database?.documentId ?? entry.target.documentId); + if (!document) continue; + let viewName: string | null = null; + let target = database + ? { ...entry.target, documentId: database.documentId } + : entry.target; + let fallback: ContentRecentResult["fallback"]; + if (entry.target.databaseId) { + if (!database || database.documentId !== document.id) continue; + if (entry.target.viewId) { + // Validate before the legacy parser, which otherwise coerces unreadable JSON to a default. + viewIdentitySchema.parse(JSON.parse(database.viewConfigJson)); + const config = parseDatabaseViewConfig(database.viewConfigJson); + const view = config.views.find( + (candidate) => candidate.id === entry.target.viewId, + ); + if (view) { + viewName = view.name; + } else { + const personal = await readPersonalDatabaseViewOverrides( + userEmail, + database.id, + ); + const fallbackView = + config.views.find( + (candidate) => candidate.id === personal?.activeViewId, + ) ?? + config.views.find( + (candidate) => candidate.id === config.activeViewId, + ) ?? + config.views[0]; + if (!fallbackView) continue; + target = { + documentId: document.id, + databaseId: database.id, + viewId: fallbackView.id, + }; + viewName = fallbackView.name; + fallback = { + reason: "saved_view_unavailable", + requestedViewId: entry.target.viewId, + }; + } + } + } + const key = contentRecentTargetKey(entry.target); + if (seen.has(key)) continue; + seen.add(key); + results.push({ + ...entry, + target, + title: document.title, + icon: document.icon, + viewName, + ...(fallback ? { fallback } : {}), + }); + } + return results; +} diff --git a/templates/content/actions/_content-sidebar-state.ts b/templates/content/actions/_content-sidebar-state.ts index 7ec19b13595..e5778229be7 100644 --- a/templates/content/actions/_content-sidebar-state.ts +++ b/templates/content/actions/_content-sidebar-state.ts @@ -1,24 +1,111 @@ import { z } from "zod"; -export const CONTENT_SIDEBAR_STATE_VERSION = 1; +import { contentSidebarSectionsSchema } from "../shared/content-personal-navigation.js"; + +export const CONTENT_SIDEBAR_STATE_VERSION = 2; export const CONTENT_SIDEBAR_STATE_SETTING_KEY = "content-sidebar-state"; +export function contentSidebarStateSettingKey(spaceId?: string) { + return spaceId + ? `${CONTENT_SIDEBAR_STATE_SETTING_KEY}:${spaceId}` + : CONTENT_SIDEBAR_STATE_SETTING_KEY; +} + const expandedIdSchema = z.string().min(1).max(256); +const contentSidebarStateV1Schema = z.object({ + version: z.literal(1), + expandedWorkspaceIds: z.array(expandedIdSchema).max(1_000).optional(), + expandedDocumentIds: z.array(expandedIdSchema).max(5_000).optional(), + sections: z + .object({ + order: z + .array(z.enum(["pinned", "recent", "workspaces"])) + .length(3) + .refine((ids) => new Set(ids).size === 3), + pinned: z.object({ + visible: z.boolean(), + expanded: z.boolean(), + limit: z.number().int().min(5).max(50), + }), + recent: z.object({ + visible: z.boolean(), + expanded: z.boolean(), + limit: z.number().int().min(5).max(50), + }), + }) + .optional(), +}); + export const contentSidebarStateSchema = z.object({ version: z.literal(CONTENT_SIDEBAR_STATE_VERSION), - expandedWorkspaceIds: z.array(expandedIdSchema).max(1_000), - expandedDocumentIds: z.array(expandedIdSchema).max(5_000), + expandedWorkspaceIds: z.array(expandedIdSchema).max(1_000).optional(), + expandedDocumentIds: z.array(expandedIdSchema).max(5_000).optional(), + sections: contentSidebarSectionsSchema.optional(), }); export type ContentSidebarState = z.infer; -export function normalizeContentSidebarState(value: unknown) { - const parsed = contentSidebarStateSchema.safeParse(value); - if (!parsed.success) return null; +export function normalizeContentSidebarState( + value: unknown, + selectedSpaceId?: string, +) { + if (value === null) return null; + const current = contentSidebarStateSchema.safeParse(value); + const data = current.success + ? current.data + : migrateContentSidebarState( + contentSidebarStateV1Schema.parse(value), + selectedSpaceId, + ); + return { + ...data, + ...(data.expandedWorkspaceIds === undefined + ? {} + : { expandedWorkspaceIds: [...new Set(data.expandedWorkspaceIds)] }), + ...(data.expandedDocumentIds === undefined + ? {} + : { expandedDocumentIds: [...new Set(data.expandedDocumentIds)] }), + }; +} + +function migrateContentSidebarState( + state: z.infer, + selectedSpaceId?: string, +): ContentSidebarState { + const sections = state.sections; return { - ...parsed.data, - expandedWorkspaceIds: [...new Set(parsed.data.expandedWorkspaceIds)], - expandedDocumentIds: [...new Set(parsed.data.expandedDocumentIds)], + version: CONTENT_SIDEBAR_STATE_VERSION, + ...(state.expandedWorkspaceIds === undefined + ? {} + : { expandedWorkspaceIds: state.expandedWorkspaceIds }), + ...(state.expandedDocumentIds === undefined + ? {} + : { expandedDocumentIds: state.expandedDocumentIds }), + ...(sections + ? { + sections: { + order: sections.order.map((id) => + id === "workspaces" ? "files" : id, + ), + pinned: { + visible: sections.pinned.visible, + expanded: sections.pinned.expanded, + }, + recent: { + visible: sections.recent.visible, + expanded: sections.recent.expanded, + }, + files: { + visible: true, + expanded: + state.expandedWorkspaceIds === undefined || + (selectedSpaceId + ? state.expandedWorkspaceIds.includes(selectedSpaceId) + : state.expandedWorkspaceIds.length > 0), + }, + }, + } + : {}), }; } diff --git a/templates/content/actions/_database-navigation.ts b/templates/content/actions/_database-navigation.ts new file mode 100644 index 00000000000..4d4d732fac7 --- /dev/null +++ b/templates/content/actions/_database-navigation.ts @@ -0,0 +1,669 @@ +import { createHash } from "node:crypto"; + +import { fail } from "@agent-native/core/action"; +import { alias } from "@agent-native/core/db/schema"; +import { accessFilter } from "@agent-native/core/sharing"; +import { + and, + asc, + desc, + eq, + exists, + gt, + inArray, + isNull, + lt, + or, + sql, + type SQL, +} from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import type { + ContentDatabaseFilter, + ContentDatabaseFilterMode, + ContentDatabaseNavigationPageResponse, + ContentDatabaseNavigationSort, +} from "../shared/api.js"; +import { readPersonalDatabaseViewOverrides } from "./_content-database-personal-view.js"; +import { favoriteDocumentIds } from "./_content-favorites.js"; +import { softDeletedDatabaseDocumentExclusions } from "./_document-discovery-query.js"; +import { parseDatabaseViewConfig } from "./_property-utils.js"; + +const CURSOR_VERSION = 1; + +type NavigationCursor = { + version: typeof CURSOR_VERSION; + databaseId: string; + parentId: string | null; + sort: ContentDatabaseNavigationSort; + viewId: string | null; + orderHash: string | null; + revision: string; + configHash: string; + sortValue: string | number; + position?: number; + itemId: string; +}; + +function invalidCursor(): never { + fail("The Files navigation cursor is invalid or stale.", { + errorCode: "invalid_navigation_cursor", + statusCode: 400, + }); +} + +function decodeCursor(value: string): NavigationCursor { + try { + const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + if ( + parsed?.version !== CURSOR_VERSION || + typeof parsed.databaseId !== "string" || + !(parsed.parentId === null || typeof parsed.parentId === "string") || + !["custom", "name", "created", "last_edited"].includes(parsed.sort) || + !(parsed.viewId === null || typeof parsed.viewId === "string") || + !(parsed.orderHash === null || typeof parsed.orderHash === "string") || + typeof parsed.revision !== "string" || + typeof parsed.configHash !== "string" || + !( + typeof parsed.sortValue === "string" || + typeof parsed.sortValue === "number" + ) || + typeof parsed.itemId !== "string" || + (parsed.sort === "custom" && + (typeof parsed.sortValue !== "number" || + typeof parsed.position !== "number")) + ) { + invalidCursor(); + } + return parsed as NavigationCursor; + } catch { + return invalidCursor(); + } +} + +function encodeCursor(cursor: NavigationCursor) { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +function orderFingerprint(itemIds: string[]) { + return createHash("sha256").update(JSON.stringify(itemIds)).digest("hex"); +} + +function selectedFilterValues(value: string) { + const trimmed = value.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!Array.isArray(parsed)) return [trimmed]; + return [ + ...new Set( + parsed + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; + } catch { + return [trimmed]; + } +} + +function activeNavigationFilter(filter: ContentDatabaseFilter) { + return ( + ["is_empty", "is_not_empty", "is_checked", "is_unchecked"].includes( + filter.operator, + ) || selectedFilterValues(filter.value).length > 0 + ); +} + +function textFilterSql( + expression: SQL, + filter: ContentDatabaseFilter, +): SQL | undefined { + const values = selectedFilterValues(filter.value).map((value) => + value.toLowerCase(), + ); + const normalized = sql`lower(coalesce(${expression}, ''))`; + if (filter.operator === "is_empty") return sql`${normalized} = ''`; + if (filter.operator === "is_not_empty") return sql`${normalized} <> ''`; + if (filter.operator === "equals") return sql`${normalized} = ${values[0]}`; + if (filter.operator === "does_not_equal") + return sql`${normalized} <> ${values[0]}`; + if (filter.operator === "contains") + return sql`position(${values[0]} in ${normalized}) > 0`; + return undefined; +} + +function dateFilterSql( + expression: SQL, + filter: ContentDatabaseFilter, +): SQL | undefined { + const values = selectedFilterValues(filter.value); + if (filter.operator === "is_empty") + return sql`coalesce(${expression}, '') = ''`; + if (filter.operator === "is_not_empty") + return sql`coalesce(${expression}, '') <> ''`; + if (filter.operator === "before") return sql`${expression} < ${values[0]}`; + if (filter.operator === "after") return sql`${expression} > ${values[0]}`; + if (filter.operator === "between" && values.length >= 2) { + const [start, end] = + values[0]! <= values[1]! ? values : [values[1]!, values[0]!]; + return and(sql`${expression} >= ${start}`, sql`${expression} <= ${end}`); + } + return undefined; +} + +async function navigationFilterSql(args: { + databaseId: string; + filters: ContentDatabaseFilter[]; + filterMode: ContentDatabaseFilterMode; + document: { + id: SQL; + title: SQL; + parentId: SQL; + ownerEmail: SQL; + createdAt: SQL; + updatedAt: SQL; + }; +}) { + const filters = args.filters.filter(activeNavigationFilter); + if (filters.length === 0) return undefined; + const propertyIds = [ + ...new Set( + filters.map((filter) => filter.key).filter((key) => key !== "name"), + ), + ]; + const definitions = propertyIds.length + ? await getDb() + .select({ + id: schema.documentPropertyDefinitions.id, + name: schema.documentPropertyDefinitions.name, + type: schema.documentPropertyDefinitions.type, + systemRole: schema.documentPropertyDefinitions.systemRole, + }) + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.databaseId, args.databaseId), + inArray(schema.documentPropertyDefinitions.id, propertyIds), + ), + ) + : []; + const definitionById = new Map( + definitions.map((definition) => [definition.id, definition]), + ); + + const leaves = filters.map((filter) => { + let predicate: SQL | undefined; + if (filter.key === "name") { + predicate = textFilterSql(args.document.title, filter); + } else { + const definition = definitionById.get(filter.key); + if (!definition) { + fail( + `The Files view filter property "${filter.label}" is unavailable.`, + { + errorCode: "unsupported_navigation_filter_property", + statusCode: 400, + }, + ); + } + if (definition.systemRole === "files_parent") { + predicate = textFilterSql(args.document.parentId, filter); + } else if (definition.systemRole === "files_source") { + const selected = selectedFilterValues(filter.value); + const sourceMatch = selected.length + ? exists( + getDb() + .select({ id: schema.contentDatabaseSourceRows.id }) + .from(schema.contentDatabaseSourceRows) + .where( + and( + eq( + schema.contentDatabaseSourceRows.documentId, + args.document.id, + ), + inArray( + schema.contentDatabaseSourceRows.sourceId, + selected, + ), + ), + ), + ) + : undefined; + const hasSource = exists( + getDb() + .select({ id: schema.contentDatabaseSourceRows.id }) + .from(schema.contentDatabaseSourceRows) + .where( + eq(schema.contentDatabaseSourceRows.documentId, args.document.id), + ), + ); + const matches = selected.includes("local") + ? sourceMatch + ? or(sourceMatch, sql`not ${hasSource}`) + : sql`not ${hasSource}` + : sourceMatch; + if (filter.operator === "equals" || filter.operator === "contains") + predicate = matches; + else if (filter.operator === "does_not_equal") + predicate = matches ? sql`not (${matches})` : undefined; + else if (filter.operator === "is_empty") predicate = sql`false`; + else if (filter.operator === "is_not_empty") predicate = sql`true`; + } else if (definition.type === "created_time") { + predicate = dateFilterSql(args.document.createdAt, filter); + } else if (definition.type === "last_edited_time") { + predicate = dateFilterSql(args.document.updatedAt, filter); + } else if ( + definition.type === "created_by" || + definition.type === "last_edited_by" + ) { + predicate = textFilterSql(args.document.ownerEmail, filter); + } + } + if (!predicate) { + fail( + `The Files view filter on "${filter.label}" is not supported in navigation.`, + { + errorCode: "unsupported_navigation_filter_expression", + statusCode: 400, + }, + ); + } + return { filter, predicate }; + }); + const roots = leaves.filter(({ filter }) => !filter.parentFilterGroupId); + const groups = new Map(); + for (const { filter, predicate } of leaves) { + if (!filter.parentFilterGroupId || !filter.filterGroupId) continue; + groups.set(filter.filterGroupId, [ + ...(groups.get(filter.filterGroupId) ?? []), + predicate, + ]); + } + const combine = (values: SQL[]): SQL => + (args.filterMode === "or" ? or(...values) : and(...values)) ?? sql`true`; + return combine([ + ...roots.map(({ predicate }) => predicate), + ...[...groups.values()].map(combine), + ]); +} + +export async function getContentDatabaseNavigationPage(args: { + database: typeof schema.contentDatabases.$inferSelect; + userEmail: string; + parentId: string | null; + sort: ContentDatabaseNavigationSort; + viewId?: string; + limit: number; + cursor?: string; +}): Promise { + if (args.database.systemRole !== "files" || !args.database.spaceId) { + fail("Navigation mode is only available for a Files database.", { + errorCode: "unsupported_navigation_database", + statusCode: 400, + }); + } + + const cursor = args.cursor ? decodeCursor(args.cursor) : null; + const sharedConfig = parseDatabaseViewConfig(args.database.viewConfigJson); + const overrides = await readPersonalDatabaseViewOverrides( + args.userEmail, + args.database.id, + ); + const viewId = + args.viewId ?? overrides?.activeViewId ?? sharedConfig.activeViewId; + const sharedView = + sharedConfig.views.find((candidate) => candidate.id === viewId) ?? + (!args.viewId + ? sharedConfig.views.find( + (candidate) => candidate.id === sharedConfig.activeViewId, + ) + : undefined); + if (!sharedView) { + fail("The selected Files view no longer exists.", { + errorCode: "invalid_navigation_view", + statusCode: 400, + }); + } + const personalView = overrides?.views.find( + (candidate) => candidate.id === viewId, + ); + const effectiveFilters = personalView?.filters ?? sharedView.filters; + const effectiveFilterMode = + personalView?.filterMode ?? sharedView.filterMode ?? "and"; + const sharedSort = (personalView?.sorts ?? sharedView.sorts)[0]; + const configuredSort = + sharedSort?.key === "name" + ? "name" + : sharedSort?.key === "created" || sharedSort?.key === "created_at" + ? "created" + : sharedSort?.key === "updated" || + sharedSort?.key === "updated_at" || + sharedSort?.key === "last_edited" + ? "last_edited" + : null; + const sidebarSort = personalView?.sidebarOrder?.mode; + const effectiveSort = sidebarSort ?? configuredSort ?? args.sort; + const descending = sidebarSort + ? sidebarSort === "created" || sidebarSort === "last_edited" + : effectiveSort !== "custom" && sharedSort?.direction === "desc"; + const itemIds = + effectiveSort === "custom" + ? (personalView?.sidebarOrder?.itemIds ?? []) + : []; + const orderHash = + effectiveSort === "custom" ? orderFingerprint(itemIds) : null; + const configHash = orderFingerprint([ + JSON.stringify({ + viewId, + sharedView, + personalView: personalView ?? null, + effectiveSort, + descending, + }), + ]); + const db = getDb(); + const accessContext = { + userEmail: args.userEmail, + orgId: args.database.orgId ?? undefined, + }; + + if (args.parentId !== null) { + const [parent] = await db + .select({ id: schema.documents.id }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, args.database.id), + eq(schema.documents.id, args.parentId), + eq(schema.documents.spaceId, args.database.spaceId), + isNull(schema.documents.trashedAt), + accessFilter(schema.documents, schema.documentShares, accessContext), + ...softDeletedDatabaseDocumentExclusions(schema.documents.id), + ), + ) + .limit(1); + if (!parent) { + fail("The Files navigation parent is unavailable.", { + errorCode: "invalid_navigation_parent", + statusCode: 400, + }); + } + } + + const effectiveFilter = await navigationFilterSql({ + databaseId: args.database.id, + filters: effectiveFilters, + filterMode: effectiveFilterMode, + document: { + id: sql`${schema.documents.id}`, + title: sql`${schema.documents.title}`, + parentId: sql`${schema.documents.parentId}`, + ownerEmail: sql`${schema.documents.ownerEmail}`, + createdAt: sql`${schema.documents.createdAt}`, + updatedAt: sql`${schema.documents.updatedAt}`, + }, + }); + + const childDocuments = alias(schema.documents, "navigation_child_documents"); + const childItems = alias( + schema.contentDatabaseItems, + "navigation_child_memberships", + ); + const databaseDocuments = alias( + schema.contentDatabases, + "navigation_database_documents", + ); + const siblingFilter = and( + eq(schema.contentDatabaseItems.databaseId, args.database.id), + args.parentId === null + ? isNull(schema.documents.parentId) + : eq(schema.documents.parentId, args.parentId), + isNull(schema.documents.trashedAt), + accessFilter(schema.documents, schema.documentShares, accessContext), + ...softDeletedDatabaseDocumentExclusions(schema.documents.id), + effectiveFilter, + ); + const accessRevision = sql`coalesce(( + SELECT string_agg(ds.role || ':' || ds.principal_type || ':' || ds.principal_id, ',' ORDER BY ds.role, ds.principal_type, ds.principal_id) + FROM document_shares ds + WHERE ds.resource_id = ${schema.documents.id} + AND ((ds.principal_type = 'user' AND lower(ds.principal_id) = lower(${args.userEmail})) + ${args.database.orgId ? sql`OR (ds.principal_type = 'org' AND ds.principal_id = ${args.database.orgId})` : sql``}) + ), '')`; + const [revisionState] = await db + .select({ + count: sql`count(*)::int`, + checksum: sql`coalesce(sum(hashtextextended(concat_ws('|', + ${schema.contentDatabaseItems.id}, ${schema.contentDatabaseItems.position}::text, + ${schema.contentDatabaseItems.updatedAt}, ${schema.documents.id}, + coalesce(${schema.documents.parentId}, ''), ${schema.documents.title}, + ${schema.documents.createdAt}, ${schema.documents.updatedAt}, + ${schema.documents.visibility}, coalesce(${schema.documents.trashedAt}, ''), + coalesce(${schema.documents.sourceKind}, ''), ${accessRevision} + ), 0)::numeric)::text, '0')`, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where(siblingFilter); + const revision = `${revisionState.count}:${revisionState.checksum}`; + if ( + cursor && + (cursor.databaseId !== args.database.id || + cursor.parentId !== args.parentId || + cursor.sort !== effectiveSort || + cursor.viewId !== viewId || + cursor.orderHash !== orderHash || + cursor.configHash !== configHash || + cursor.revision !== revision) + ) { + invalidCursor(); + } + const visibleChild = and( + eq(childItems.databaseId, args.database.id), + eq(childDocuments.parentId, schema.documents.id), + isNull(childDocuments.trashedAt), + accessFilter(childDocuments, schema.documentShares, accessContext), + ...softDeletedDatabaseDocumentExclusions(childDocuments.id), + await navigationFilterSql({ + databaseId: args.database.id, + filters: effectiveFilters, + filterMode: effectiveFilterMode, + document: { + id: sql`${childDocuments.id}`, + title: sql`${childDocuments.title}`, + parentId: sql`${childDocuments.parentId}`, + ownerEmail: sql`${childDocuments.ownerEmail}`, + createdAt: sql`${childDocuments.createdAt}`, + updatedAt: sql`${childDocuments.updatedAt}`, + }, + }), + ); + const activeDatabaseDocument = db + .select({ id: databaseDocuments.id }) + .from(databaseDocuments) + .where( + and( + eq(databaseDocuments.documentId, schema.documents.id), + isNull(databaseDocuments.deletedAt), + ), + ); + const customRank = + itemIds.length > 0 + ? sql`COALESCE(array_position(ARRAY[${sql.join( + itemIds.map((itemId) => sql`${itemId}`), + sql`, `, + )}]::text[], ${schema.contentDatabaseItems.id}), ${itemIds.length + 1})` + : sql`CAST(1 AS integer)`; + const sortValue: SQL = + effectiveSort === "custom" + ? customRank + : effectiveSort === "name" + ? sql`${schema.documents.title}` + : effectiveSort === "created" + ? sql`${schema.documents.createdAt}` + : sql`${schema.documents.updatedAt}`; + const ascending = effectiveSort === "custom" || !descending; + const cursorFilter = cursor + ? effectiveSort === "custom" + ? or( + gt(customRank, Number(cursor.sortValue)), + and( + eq(customRank, Number(cursor.sortValue)), + gt(schema.contentDatabaseItems.position, cursor.position!), + ), + and( + eq(customRank, Number(cursor.sortValue)), + eq(schema.contentDatabaseItems.position, cursor.position!), + gt(schema.contentDatabaseItems.id, cursor.itemId), + ), + ) + : or( + ascending + ? gt(sortValue, cursor.sortValue) + : lt(sortValue, cursor.sortValue), + and( + eq(sortValue, cursor.sortValue), + gt(schema.contentDatabaseItems.id, cursor.itemId), + ), + ) + : undefined; + + const rows = await db + .select({ + membershipId: schema.contentDatabaseItems.id, + membershipPosition: schema.contentDatabaseItems.position, + documentId: schema.documents.id, + parentId: schema.documents.parentId, + title: schema.documents.title, + icon: schema.documents.icon, + spaceId: schema.documents.spaceId, + sourceKind: schema.documents.sourceKind, + ownerEmail: schema.documents.ownerEmail, + createdAt: schema.documents.createdAt, + updatedAt: schema.documents.updatedAt, + sortValue, + type: sql<"page" | "database">`CASE WHEN ${exists( + activeDatabaseDocument, + )} THEN 'database' ELSE 'page' END`, + hasChildren: sql`${exists( + db + .select({ id: childItems.id }) + .from(childItems) + .innerJoin( + childDocuments, + eq(childDocuments.id, childItems.documentId), + ) + .where(visibleChild), + )}`, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where(and(siblingFilter, cursorFilter)) + .orderBy( + ascending ? asc(sortValue) : desc(sortValue), + ...(effectiveSort === "custom" + ? [asc(schema.contentDatabaseItems.position)] + : []), + asc(schema.contentDatabaseItems.id), + ) + .limit(args.limit + 1); + + const hasMore = rows.length > args.limit; + const pageRows = rows.slice(0, args.limit); + const favoriteIds = await favoriteDocumentIds( + db, + args.userEmail, + pageRows.map((row) => row.documentId), + ); + const shareRows = pageRows.length + ? await db + .select({ + resourceId: schema.documentShares.resourceId, + role: schema.documentShares.role, + }) + .from(schema.documentShares) + .where( + and( + inArray( + schema.documentShares.resourceId, + pageRows.map((row) => row.documentId), + ), + or( + and( + eq(schema.documentShares.principalType, "user"), + eq(schema.documentShares.principalId, args.userEmail), + ), + ...(args.database.orgId + ? [ + and( + eq(schema.documentShares.principalType, "org"), + eq( + schema.documentShares.principalId, + args.database.orgId, + ), + ), + ] + : []), + ), + ), + ) + : []; + const editableIds = new Set( + shareRows + .filter((row) => ["owner", "admin", "editor"].includes(row.role)) + .map((row) => row.resourceId), + ); + const manageableIds = new Set( + shareRows + .filter((row) => ["owner", "admin"].includes(row.role)) + .map((row) => row.resourceId), + ); + const last = pageRows[pageRows.length - 1]; + return { + items: pageRows.map(({ sortValue: _sortValue, ownerEmail, ...row }) => { + const isOwner = ownerEmail.toLowerCase() === args.userEmail.toLowerCase(); + return { + ...row, + isFavorite: favoriteIds.has(row.documentId), + canEdit: isOwner || editableIds.has(row.documentId), + canManage: isOwner || manageableIds.has(row.documentId), + }; + }), + pagination: { + limit: args.limit, + hasMore, + nextCursor: + hasMore && last + ? encodeCursor({ + version: CURSOR_VERSION, + databaseId: args.database.id, + parentId: args.parentId, + sort: effectiveSort, + viewId, + orderHash, + revision, + configHash, + sortValue: last.sortValue, + position: + effectiveSort === "custom" + ? last.membershipPosition + : undefined, + itemId: last.membershipId, + }) + : null, + }, + }; +} diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 32f17c01dc8..baf7a38158f 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -1,3 +1,4 @@ +import { alias } from "@agent-native/core/db/schema"; import { getRequestOrgId, getRequestUserEmail, @@ -8,7 +9,17 @@ import { resolveAccess, type ShareRole, } from "@agent-native/core/sharing"; -import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + exists, + inArray, + isNull, + or, + sql, +} from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -24,6 +35,7 @@ import type { ContentDatabaseMembership, ContentDatabaseResponse, ContentDatabaseTableQuery, + ContentSidebarViewOrder, DocumentProperty, } from "../shared/api.js"; import { @@ -562,6 +574,34 @@ type ContentDatabasePageBuild = ContentDatabasePageResponse & { hydratedItemCount: number; }; +const scopedFilesMemberships = alias( + schema.contentDatabaseItems, + "scoped_files_memberships", +); + +export function contentDatabaseFilesMembershipFilter(filesDatabaseId: string) { + return exists( + getDb() + .select({ id: scopedFilesMemberships.id }) + .from(scopedFilesMemberships) + .where( + and( + eq(scopedFilesMemberships.databaseId, filesDatabaseId), + eq( + scopedFilesMemberships.documentId, + schema.contentDatabaseItems.documentId, + ), + ), + ), + ); +} + +export function contentDatabaseCustomOrderRank(itemIds: string[]) { + return itemIds.length > 0 + ? sql`COALESCE(array_position(ARRAY(SELECT jsonb_array_elements_text(${JSON.stringify(itemIds)}::jsonb)), ${schema.contentDatabaseItems.id}), ${itemIds.length + 1})` + : sql`CAST(1 AS integer)`; +} + export async function getContentDatabasePageResponse( databaseId: string, options: { @@ -570,6 +610,8 @@ export async function getContentDatabasePageResponse( tableQuery?: ContentDatabaseTableQuery; includeSources?: boolean; documentIds?: string[]; + filesMembershipDatabaseId?: string; + sidebarOrder?: ContentSidebarViewOrder; database?: typeof schema.contentDatabases.$inferSelect; } = {}, ): Promise { @@ -654,33 +696,28 @@ export async function getContentDatabasePageResponse( ) .map((row) => row.documentId) : null; - const favoritesVisibleDocumentIds = + const favoritesVisibleDocumentQuery = database.systemRole === "favorites" && userEmail - ? ( - await db - .select({ id: schema.documents.id }) - .from(schema.documents) - .where( - and( - or( + ? db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + or( + accessFilter(schema.documents, schema.documentShares, { + userEmail, + }), + ...authorizedOrgIds.map((orgId) => accessFilter(schema.documents, schema.documentShares, { userEmail, + orgId, }), - ...authorizedOrgIds.map((orgId) => - accessFilter(schema.documents, schema.documentShares, { - userEmail, - orgId, - }), - ), ), - isNull(schema.documents.trashedAt), - documentDiscoveryFilter({ - userEmail, - orgIds: authorizedOrgIds, - }), ), - ) - ).map((document) => document.id) + isNull(schema.documents.trashedAt), + documentDiscoveryFilter({ userEmail, orgIds: authorizedOrgIds }), + ), + ) : null; const organizationFilesItemFilter = database.systemRole === "files" && database.orgId @@ -699,6 +736,9 @@ export async function getContentDatabasePageResponse( ? inArray(schema.contentDatabaseItems.documentId, options.documentIds) : sql`1 = 0` : undefined, + options.filesMembershipDatabaseId + ? contentDatabaseFilesMembershipFilter(options.filesMembershipDatabaseId) + : undefined, sql`exists ( select 1 from ${schema.documents} where ${schema.documents.id} = ${schema.contentDatabaseItems.documentId} @@ -712,13 +752,11 @@ export async function getContentDatabasePageResponse( ) : undefined, organizationFilesItemFilter, - favoritesVisibleDocumentIds - ? favoritesVisibleDocumentIds.length > 0 - ? inArray( - schema.contentDatabaseItems.documentId, - favoritesVisibleDocumentIds, - ) - : sql`1 = 0` + favoritesVisibleDocumentQuery + ? inArray( + schema.contentDatabaseItems.documentId, + favoritesVisibleDocumentQuery, + ) : undefined, workspacesVisibleDocumentIds ? workspacesVisibleDocumentIds.length > 0 @@ -749,22 +787,60 @@ export async function getContentDatabasePageResponse( ) : null; - let itemsQuery = db - .select() - .from(schema.contentDatabaseItems) - .where(visibleItemFilter) - .orderBy( - asc(schema.contentDatabaseItems.position), - asc(schema.contentDatabaseItems.createdAt), - asc(schema.contentDatabaseItems.id), - ) - .$dynamic(); - if (serverTableQuery) { - itemsQuery = itemsQuery.limit(CONTENT_DATABASE_MAX_READ_LIMIT); - } else if (limit !== null) { - itemsQuery = itemsQuery.limit(limit).offset(offset); + let items; + if (options.sidebarOrder && !serverTableQuery) { + const customItemIds = options.sidebarOrder.itemIds; + const customRank = contentDatabaseCustomOrderRank(customItemIds); + const order = + options.sidebarOrder.mode === "custom" + ? [ + asc(customRank), + asc(schema.contentDatabaseItems.position), + asc(schema.contentDatabaseItems.id), + ] + : options.sidebarOrder.mode === "name" + ? [asc(schema.documents.title), asc(schema.contentDatabaseItems.id)] + : options.sidebarOrder.mode === "created" + ? [ + desc(schema.documents.createdAt), + asc(schema.contentDatabaseItems.id), + ] + : [ + desc(schema.documents.updatedAt), + asc(schema.contentDatabaseItems.id), + ]; + let orderedItemsQuery = db + .select({ item: schema.contentDatabaseItems }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where(visibleItemFilter) + .orderBy(...order) + .$dynamic(); + if (limit !== null) { + orderedItemsQuery = orderedItemsQuery.limit(limit).offset(offset); + } + items = (await orderedItemsQuery).map((row) => row.item); + } else { + let itemsQuery = db + .select() + .from(schema.contentDatabaseItems) + .where(visibleItemFilter) + .orderBy( + asc(schema.contentDatabaseItems.position), + asc(schema.contentDatabaseItems.createdAt), + asc(schema.contentDatabaseItems.id), + ) + .$dynamic(); + if (serverTableQuery) { + itemsQuery = itemsQuery.limit(CONTENT_DATABASE_MAX_READ_LIMIT); + } else if (limit !== null) { + itemsQuery = itemsQuery.limit(limit).offset(offset); + } + items = await itemsQuery; } - let items = await itemsQuery; let boundedTableQueryTotal: number | null = null; if (serverTableQuery && boundedProjectionPropertyIds) { const candidateDocuments = await db @@ -938,27 +1014,23 @@ export async function getContentDatabasePageResponse( items.map((item) => item.documentId), ), isNull(schema.documents.trashedAt), - database.systemRole === "favorites" - ? favoritesVisibleDocumentIds?.length - ? inArray(schema.documents.id, favoritesVisibleDocumentIds) + database.systemRole === "workspaces" + ? workspacesVisibleDocumentIds?.length + ? inArray(schema.documents.id, workspacesVisibleDocumentIds) : sql`1 = 0` - : 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), + : 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), ), ) : []; @@ -1070,6 +1142,38 @@ export async function getContentDatabasePageResponse( ).map((row) => row.databaseItemId), ) : new Set(); + const workspaceMemberships = + database.systemRole === "favorites" && documents.length > 0 + ? await db + .select({ + documentId: schema.contentDatabaseItems.documentId, + databaseId: schema.contentDatabaseItems.databaseId, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.contentDatabases, + eq( + schema.contentDatabases.id, + schema.contentDatabaseItems.databaseId, + ), + ) + .where( + and( + inArray( + schema.contentDatabaseItems.documentId, + documents.map((document) => document.id), + ), + eq(schema.contentDatabases.systemRole, "files"), + isNull(schema.contentDatabases.deletedAt), + ), + ) + : []; + const workspaceDatabaseIdByDocumentId = new Map( + workspaceMemberships.map((membership) => [ + membership.documentId, + membership.databaseId, + ]), + ); const serializedCandidateItems = []; for (const item of items) { @@ -1090,6 +1194,8 @@ export async function getContentDatabasePageResponse( favorites.has(document.id), ), position: item.position, + workspaceFilesDatabaseId: + workspaceDatabaseIdByDocumentId.get(document.id) ?? null, bodyHydration: serializeBodyHydration(item, { queued: bodyHydrationQueued, }), @@ -1223,6 +1329,8 @@ export async function getContentDatabaseResponse( tableQuery?: ContentDatabaseTableQuery; includeSources?: boolean; documentIds?: string[]; + filesMembershipDatabaseId?: string; + sidebarOrder?: ContentSidebarViewOrder; database?: typeof schema.contentDatabases.$inferSelect; } = {}, ): Promise { diff --git a/templates/content/actions/_local-file-documents.ts b/templates/content/actions/_local-file-documents.ts index b59adaf643f..6ccf8f96ba7 100644 --- a/templates/content/actions/_local-file-documents.ts +++ b/templates/content/actions/_local-file-documents.ts @@ -56,6 +56,11 @@ function localOptions(): LocalArtifactOptions { }; } +export async function getContentSourceMode() { + const app = await getLocalArtifactApp(localOptions()); + return app.mode; +} + function encodeIdPath(filePath: string): string { return Buffer.from(filePath, "utf8").toString("base64url"); } diff --git a/templates/content/actions/_user-setting-transaction.ts b/templates/content/actions/_user-setting-transaction.ts new file mode 100644 index 00000000000..b67856a0413 --- /dev/null +++ b/templates/content/actions/_user-setting-transaction.ts @@ -0,0 +1,93 @@ +import { getSetting, getSettingsEmitter } from "@agent-native/core/settings"; +import { sql, type SQL } from "drizzle-orm"; + +interface SettingsTransaction { + execute(query: SQL): Promise; +} + +type TransactionMutation = { + value: Record | null; + result: T; +}; + +function userKey(email: string, key: string): string { + return `u:${email.trim().toLowerCase()}:${key}`; +} + +function legacyUserKey(email: string, key: string): string { + return `u:${email}:${key}`; +} + +function transactionRows(result: unknown): Record[] { + if (Array.isArray(result)) return result as Record[]; + const rows = (result as { rows?: unknown } | null)?.rows; + return Array.isArray(rows) ? (rows as Record[]) : []; +} + +/** Keep Content data and its personal navigation setting in one commit. */ +export async function mutateContentUserSettingTransaction< + TTransaction extends SettingsTransaction, + TResult, +>( + runTransaction: (callback: (tx: TTransaction) => Promise) => Promise, + email: string, + key: string, + updater: ( + tx: TTransaction, + current: Record | null, + ) => Promise> | TransactionMutation, +): Promise> { + const normalized = userKey(email, key); + const legacy = legacyUserKey(email, key); + + // Core owns the lazy settings-table setup. Bypass its request cache because + // the transaction below is the authoritative read and write for this action. + await getSetting(normalized, { bypassCache: true }); + + const mutation = await runTransaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${normalized}, 0::bigint))`, + ); + const currentResult = await tx.execute( + sql`SELECT value FROM public.settings WHERE key = ${normalized} FOR UPDATE`, + ); + const currentRow = transactionRows(currentResult)[0]; + let current = currentRow?.value + ? (JSON.parse(String(currentRow.value)) as Record) + : null; + if (current === null && legacy !== normalized) { + const fallbackResult = await tx.execute( + sql`SELECT value FROM public.settings WHERE key = ${legacy} FOR UPDATE`, + ); + const fallbackRow = transactionRows(fallbackResult)[0]; + current = fallbackRow?.value + ? (JSON.parse(String(fallbackRow.value)) as Record) + : null; + } + + const next = await updater(tx, current); + if (next.value === null) { + await tx.execute( + sql`DELETE FROM public.settings WHERE key = ${normalized}`, + ); + } else { + await tx.execute(sql` + INSERT INTO public.settings (key, value, updated_at) + VALUES (${normalized}, ${JSON.stringify(next.value)}, ${Date.now()}) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at + `); + } + if (legacy !== normalized) { + await tx.execute(sql`DELETE FROM public.settings WHERE key = ${legacy}`); + } + return next; + }); + + getSettingsEmitter().emit("settings", { + source: "settings", + type: mutation.value === null ? "delete" : "change", + key: normalized, + }); + return mutation; +} diff --git a/templates/content/actions/content-navigation-bounds.test.ts b/templates/content/actions/content-navigation-bounds.test.ts new file mode 100644 index 00000000000..55796e5fb1e --- /dev/null +++ b/templates/content/actions/content-navigation-bounds.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +function source(file: string) { + return readFileSync(new URL(file, import.meta.url), "utf8"); +} + +describe("bounded Content navigation reads", () => { + it("filters Favorites access inside the membership query", () => { + const databaseUtils = source("./_database-utils.ts"); + + expect(databaseUtils).toContain("favoritesVisibleDocumentQuery"); + expect(databaseUtils).toContain( + "inArray(\n schema.contentDatabaseItems.documentId,\n favoritesVisibleDocumentQuery", + ); + expect(databaseUtils).not.toContain("favoritesVisibleDocumentIds"); + }); + + it("caps active ancestry and returns explicit workspace metadata", () => { + const action = source("./get-content-navigation-context.ts"); + + expect(action).toContain("const MAX_ANCESTORS = 100"); + expect(action).toContain("workspaceFilesDatabaseId"); + expect(action).not.toContain("listLocalFileDocuments"); + }); + + it("returns an explicit active navigation outcome after deletion", () => { + const action = source("./delete-document.ts"); + + expect(action).toContain("activeTargetDeleted"); + expect(action).toContain("navigationPath:"); + expect(action).toContain('? "/home"'); + }); +}); diff --git a/templates/content/actions/content-personal-navigation-patch.test.ts b/templates/content/actions/content-personal-navigation-patch.test.ts new file mode 100644 index 00000000000..21837d9cf8c --- /dev/null +++ b/templates/content/actions/content-personal-navigation-patch.test.ts @@ -0,0 +1,377 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const boundary = vi.hoisted(() => ({ + access: vi.fn(), + select: vi.fn(), + mutate: vi.fn(), + read: vi.fn(), +})); + +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: boundary.read, +})); +vi.mock("./_user-setting-transaction.js", () => ({ + mutateContentUserSettingTransaction: boundary.mutate, +})); +vi.mock("../server/db/index.js", () => ({ + getDb: () => ({ + select: boundary.select, + transaction: (callback: (tx: unknown) => unknown) => + callback({ select: boundary.select }), + }), + schema: { + contentDatabases: { id: "database-id", viewConfigJson: "view-config" }, + contentDatabaseItems: { id: "item-id", databaseId: "database-id" }, + }, +})); +vi.mock("./_content-database-personal-view.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("./_content-database-personal-view.js") + >()), + assertContentDatabaseViewerAccess: boundary.access, +})); + +import { + CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + type ContentDatabasePersonalViewOverrides, +} from "../shared/api.js"; +import { readPersonalDatabaseViewOverrides } from "./_content-database-personal-view.js"; +import { filesParentPropertyId } from "./_files-system-properties.js"; +import action from "./update-content-database-personal-view.js"; + +const ctx = { userEmail: "navigation@example.test" }; +let saved: unknown; +let systemRole: string | null; + +function initialState(): ContentDatabasePersonalViewOverrides { + return { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: "table", + views: [ + { + id: "table", + sorts: [{ key: "title", label: "Title", direction: "desc" }], + filters: [ + { key: "status", label: "Status", operator: "equals", value: "open" }, + ], + filterMode: "or", + sidebarOrder: { mode: "custom", itemIds: ["old-item"] }, + }, + { + id: "board", + sorts: [{ key: "priority", label: "Priority", direction: "asc" }], + filters: [], + filterMode: "and", + sidebarOrder: { mode: "name", itemIds: ["board-item"] }, + }, + ], + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + saved = initialState(); + systemRole = null; + boundary.access.mockResolvedValue(undefined); + boundary.read.mockImplementation(async () => saved); + boundary.select.mockImplementation((projection: Record) => ({ + from: () => ({ + where: async () => + "viewConfigJson" in projection + ? [ + { + systemRole, + viewConfigJson: JSON.stringify({ + views: initialState().views, + }), + }, + ] + : "systemRole" in projection + ? [{ systemRole }] + : [{ id: "item-a" }, { id: "item-b" }], + }), + })); + // Serialize callbacks at the settings boundary; SQL/CAS belongs to its own tests. + let queue = Promise.resolve(); + boundary.mutate.mockImplementation( + ( + runTransaction: (callback: (tx: unknown) => unknown) => unknown, + _email: string, + _key: string, + mutate: ( + tx: unknown, + current: unknown, + ) => { + value: unknown; + result: unknown; + }, + ) => { + const result = queue.then(async () => { + const mutation = (await runTransaction((tx) => mutate(tx, saved))) as { + value: unknown; + result: unknown; + }; + saved = mutation.value; + return mutation; + }); + queue = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + ); +}); + +describe("personal navigation patch action", () => { + it("distinguishes absent personal state from unreadable stored state on reads", async () => { + saved = null; + expect( + await readPersonalDatabaseViewOverrides(ctx.userEmail, "db"), + ).toBeNull(); + saved = { version: 99 }; + await expect( + readPersonalDatabaseViewOverrides(ctx.userEmail, "db"), + ).rejects.toThrow(); + }); + it("rejects a persisted 5,001-item sidebar order instead of truncating it", async () => { + saved = { + ...initialState(), + views: [ + { + ...initialState().views[0], + sidebarOrder: { + mode: "custom", + itemIds: Array.from( + { length: 5_001 }, + (_, index) => `item-${index}`, + ), + }, + }, + ], + }; + + await expect( + readPersonalDatabaseViewOverrides(ctx.userEmail, "db"), + ).rejects.toMatchObject({ name: "ZodError" }); + expect( + (saved as ContentDatabasePersonalViewOverrides).views[0].sidebarOrder + ?.itemIds, + ).toHaveLength(5_001); + }); + it("seeds the first sidebar override from the shared View's query", async () => { + saved = null; + await action.run( + { + databaseId: "db", + navigation: { + sidebarOrder: { + viewId: "table", + mode: "custom", + itemIds: ["item-b", "item-a"], + }, + }, + }, + ctx, + ); + expect(saved).toMatchObject({ + views: [ + { + ...initialState().views[0], + sidebarOrder: { mode: "custom", itemIds: ["item-b", "item-a"] }, + }, + ], + }); + }); + it("prepends and removes one validated membership without replacing the saved tail", async () => { + saved = initialState(); + await action.run( + { + databaseId: "db", + navigation: { + sidebarOrder: { + operation: "prepend", + viewId: "table", + itemId: "item-a", + }, + }, + }, + ctx, + ); + expect( + (saved as ContentDatabasePersonalViewOverrides).views[0].sidebarOrder, + ).toEqual({ mode: "custom", itemIds: ["item-a", "old-item"] }); + await action.run( + { + databaseId: "db", + navigation: { + sidebarOrder: { + operation: "remove", + viewId: "table", + itemId: "item-a", + }, + }, + }, + ctx, + ); + expect( + (saved as ContentDatabasePersonalViewOverrides).views[0].sidebarOrder, + ).toEqual({ mode: "custom", itemIds: ["old-item"] }); + }); + + it.each([null, "files"])( + "migrates v1 inside the atomic update (system role: %s)", + async (role) => { + systemRole = role; + const original = initialState(); + const legacyParentFilter = { + key: filesParentPropertyId("db"), + label: "Parent", + operator: "is_empty" as const, + value: "", + }; + saved = { + ...original, + version: 1, + views: [ + { + ...original.views[0], + filters: [...original.views[0].filters, legacyParentFilter], + }, + original.views[1], + ], + }; + const migratedRead = await readPersonalDatabaseViewOverrides( + ctx.userEmail, + "db", + ); + await action.run( + { databaseId: "db", navigation: { activeViewId: "board" } }, + ctx, + ); + expect(saved).toEqual({ ...migratedRead, activeViewId: "board" }); + expect(saved).toEqual({ + ...original, + activeViewId: "board", + views: [ + { + ...original.views[0], + filters: + role === "files" + ? original.views[0].filters + : [...original.views[0].filters, legacyParentFilter], + }, + original.views[1], + ], + }); + }, + ); + it.each([false, true])( + "preserves overlapping selection and sidebar edits (reverse dispatch: %s)", + async (reverse) => { + const original = initialState(); + const select = () => + action.run( + { databaseId: "db", navigation: { activeViewId: "board" } }, + ctx, + ); + const reorder = () => + action.run( + { + databaseId: "db", + navigation: { + sidebarOrder: { + viewId: "table", + mode: "custom", + itemIds: ["item-b", "foreign-item", "item-a", "item-b"], + }, + }, + }, + ctx, + ); + await Promise.all( + (reverse ? [reorder, select] : [select, reorder]).map((run) => run()), + ); + expect(saved).toEqual({ + ...original, + activeViewId: "board", + views: [ + { + ...original.views[0], + sidebarOrder: { mode: "custom", itemIds: ["item-b", "item-a"] }, + }, + original.views[1], + ], + }); + expect(boundary.access).toHaveBeenCalledTimes(2); + expect(boundary.mutate).toHaveBeenCalledTimes(2); + expect(boundary.mutate).toHaveBeenCalledWith( + expect.any(Function), + ctx.userEmail, + "content-database-personal-view:db", + expect.any(Function), + ); + }, + ); + + it.each([ + { activeViewId: "deleted-view" }, + { + sidebarOrder: { + viewId: "deleted-view", + mode: "custom" as const, + itemIds: [], + }, + }, + ])( + "rejects an unavailable exact View without changing personal state: %j", + async (navigation) => { + await expect( + action.run({ databaseId: "db", navigation }, ctx), + ).rejects.toThrow("View is unavailable"); + expect(saved).toEqual(initialState()); + expect(boundary.mutate).not.toHaveBeenCalled(); + }, + ); + + it("stops before loading View metadata when database access is denied", async () => { + boundary.access.mockRejectedValueOnce(new Error("Access denied")); + await expect( + action.run( + { databaseId: "db", navigation: { activeViewId: "board" } }, + ctx, + ), + ).rejects.toThrow("Access denied"); + expect(boundary.select).not.toHaveBeenCalled(); + expect(boundary.mutate).not.toHaveBeenCalled(); + }); + + it("requires exactly one update form", () => { + expect(() => action.schema.parse({ databaseId: "db" })).toThrow(); + expect(() => + action.schema.parse({ + databaseId: "db", + overrides: initialState(), + navigation: { activeViewId: "board" }, + }), + ).toThrow(); + expect(() => + action.schema.parse({ + databaseId: "db", + overrides: null, + navigation: { activeViewId: "board" }, + }), + ).toThrow(); + }); + + it("does not replace unreadable personal state during a navigation update", async () => { + saved = { version: 99 }; + await expect( + action.run( + { databaseId: "db", navigation: { activeViewId: "board" } }, + ctx, + ), + ).rejects.toThrow(); + expect(saved).toEqual({ version: 99 }); + }); +}); diff --git a/templates/content/actions/content-recent.test.ts b/templates/content/actions/content-recent.test.ts new file mode 100644 index 00000000000..9fbb06cfef4 --- /dev/null +++ b/templates/content/actions/content-recent.test.ts @@ -0,0 +1,570 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const boundary = vi.hoisted(() => ({ + orgId: null as string | null, + spaceOrgId: null as string | null, + discovery: vi.fn(), + select: vi.fn(), + getSetting: vi.fn(), + mutateSetting: vi.fn(), +})); + +vi.mock("@agent-native/core/server/request-context", () => ({ + getRequestOrgId: () => boundary.orgId, +})); +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: boundary.getSetting, + mutateUserSetting: boundary.mutateSetting, +})); +vi.mock("./_document-discovery-query.js", () => ({ + documentDiscoveryWhere: boundary.discovery, +})); +vi.mock("./_content-space-access.js", () => ({ + resolveContentSpaceAccess: async () => ({ + space: { + filesDatabaseId: "files-db", + orgId: boundary.spaceOrgId, + }, + }), +})); +vi.mock("../server/db/index.js", () => ({ + getDb: () => ({ select: boundary.select }), + schema: { + documents: { id: "document-id", title: "title", icon: "icon" }, + contentDatabases: { + id: "database-id", + documentId: "database-document-id", + viewConfigJson: "view-config", + deletedAt: "deleted-at", + }, + contentDatabaseItems: { + databaseId: "item-database-id", + documentId: "item-document-id", + }, + }, +})); + +import { + defaultContentSidebarSections, + type ContentRecentEntry, +} from "../shared/content-personal-navigation.js"; +import { + contentRecentSettingKey, + resolveContentRecentEntries, +} from "./_content-recent.js"; +import getRecent from "./get-content-recent.js"; +import recordVisit from "./record-content-visit.js"; +import updateSidebar from "./update-content-sidebar-state.js"; + +const alice = { userEmail: "alice@example.test" }; +const bob = { userEmail: "bob@example.test" }; +const entry = (documentId: string, extra = {}): ContentRecentEntry => ({ + target: { documentId, ...extra }, + visitedAt: "2026-09-09T12:00:00.000Z", +}); +const stored = new Map(); +const settingId = (email: string, key: string) => JSON.stringify([email, key]); + +function rowsOnce(rows: unknown[]) { + boundary.select.mockReturnValueOnce({ + from: () => ({ where: async () => rows }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + boundary.select.mockReset(); + boundary.orgId = null; + boundary.spaceOrgId = null; + stored.clear(); + boundary.getSetting.mockImplementation( + async (email: string, key: string) => + stored.get(settingId(email, key)) ?? null, + ); + // Model the settings mutation boundary's serialization, not its SQL/CAS implementation. + let queue = Promise.resolve(); + boundary.mutateSetting.mockImplementation( + (email: string, key: string, mutate: (current: unknown) => unknown) => { + const operation = queue.then(() => { + const id = settingId(email, key); + const next = mutate(stored.get(id) ?? null); + stored.set(id, next); + return next; + }); + queue = operation.then( + () => undefined, + () => undefined, + ); + return operation; + }, + ); +}); + +describe("Recent access resolution", () => { + it("rejects corrupt View identities rather than turning them into an implicit default", async () => { + rowsOnce([{ id: "page", title: "Database", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "page", + viewConfigJson: JSON.stringify({ + views: [{ id: "", name: "Invalid" }], + }), + }, + ]); + await expect( + resolveContentRecentEntries(alice.userEmail, [ + entry("page", { databaseId: "db", viewId: "default" }), + ]), + ).rejects.toThrow(); + }); + + it.each([{}, { views: [] }, { sorts: [], filters: [], columnWidths: {} }])( + "resolves the canonical initial Table from legacy configuration %j", + async (viewConfig) => { + const visit = entry("page", { databaseId: "db", viewId: "default" }); + rowsOnce([{ id: "page", title: "New database", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "page", + viewConfigJson: JSON.stringify(viewConfig), + }, + ]); + expect( + await resolveContentRecentEntries(alice.userEmail, [visit]), + ).toEqual([ + { ...visit, title: "New database", icon: null, viewName: "Table" }, + ]); + }, + ); + + it("falls back explicitly to the first saved View when the recent View was removed", async () => { + rowsOnce([{ id: "page", title: "Database", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "page", + viewConfigJson: JSON.stringify({ + views: [{ id: "board", name: "Board" }], + }), + }, + ]); + expect( + await resolveContentRecentEntries(alice.userEmail, [ + entry("page", { databaseId: "db", viewId: "default" }), + ]), + ).toEqual([ + { + ...entry("page", { databaseId: "db", viewId: "default" }), + target: { documentId: "page", databaseId: "db", viewId: "board" }, + title: "Database", + icon: null, + viewName: "Board", + fallback: { + reason: "saved_view_unavailable", + requestedViewId: "default", + }, + }, + ]); + }); + + it("uses only the current org context and the requesting user", async () => { + boundary.orgId = "current-org"; + rowsOnce([]); + await resolveContentRecentEntries(alice.userEmail, [entry("page")]); + expect(boundary.discovery).toHaveBeenLastCalledWith( + expect.objectContaining({ + userEmail: alice.userEmail, + authorizedOrgIds: ["current-org"], + }), + ); + boundary.orgId = null; + rowsOnce([]); + await resolveContentRecentEntries(alice.userEmail, [entry("page")]); + expect(boundary.discovery).toHaveBeenLastCalledWith( + expect.objectContaining({ + authorizedOrgIds: [], + }), + ); + }); + + it("resolves fresh labels and omits rows no longer returned by access discovery", async () => { + rowsOnce([{ id: "page", title: "Current title", icon: null }]); + const visits = [entry("page"), entry("revoked"), entry("trashed")]; + expect(await resolveContentRecentEntries(alice.userEmail, visits)).toEqual([ + { ...visits[0], title: "Current title", icon: null, viewName: null }, + ]); + rowsOnce([]); + expect(await resolveContentRecentEntries(alice.userEmail, visits)).toEqual( + [], + ); + expect(boundary.select).toHaveBeenCalledTimes(2); + }); + + it("filters by current Files membership before resolving Recent rows", async () => { + rowsOnce([{ documentId: "in-space" }]); + rowsOnce([{ id: "in-space", title: "In space", icon: null }]); + expect( + await resolveContentRecentEntries( + alice.userEmail, + [entry("outside"), entry("in-space")], + "space-a", + ), + ).toEqual([ + { + ...entry("in-space"), + title: "In space", + icon: null, + viewName: null, + }, + ]); + expect(boundary.discovery).toHaveBeenCalledWith( + expect.objectContaining({ additional: expect.anything() }), + ); + }); + + it("returns no Recent rows when the selected space belongs to another org", async () => { + boundary.orgId = "org-a"; + boundary.spaceOrgId = "org-b"; + expect( + await resolveContentRecentEntries( + alice.userEmail, + [entry("org-b-page")], + "org-b-space", + ), + ).toEqual([]); + expect(boundary.select).not.toHaveBeenCalled(); + expect(boundary.discovery).not.toHaveBeenCalled(); + }); + + it("keeps the get action empty for an authorized space in another org", async () => { + boundary.orgId = "org-a"; + boundary.spaceOrgId = "org-b"; + stored.set(settingId(alice.userEmail, contentRecentSettingKey()), { + version: 2, + entries: [entry("org-b-page")], + }); + const spaceId = "org-b-space"; + expect(await getRecent.run({ spaceId }, alice)).toEqual({ + entries: [], + scopeKey: JSON.stringify([alice.userEmail, "org-a", spaceId]), + }); + expect(boundary.select).not.toHaveBeenCalled(); + }); + + it("resolves scoped Recent rows when the selected space matches the active org", async () => { + boundary.orgId = "org-a"; + boundary.spaceOrgId = "org-a"; + rowsOnce([{ documentId: "org-a-page" }]); + rowsOnce([{ id: "org-a-page", title: "Org A", icon: null }]); + expect( + await resolveContentRecentEntries( + alice.userEmail, + [entry("org-a-page")], + "org-a-space", + ), + ).toEqual([ + { + ...entry("org-a-page"), + title: "Org A", + icon: null, + viewName: null, + }, + ]); + expect(boundary.discovery).toHaveBeenCalledWith( + expect.objectContaining({ authorizedOrgIds: ["org-a"] }), + ); + }); + + it("falls back within the same database and omits an unavailable database", async () => { + rowsOnce([{ id: "page", title: "Database page", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "page", + viewConfigJson: JSON.stringify({ + activeViewId: "default", + views: [{ id: "default", name: "Default" }], + }), + }, + ]); + expect( + await resolveContentRecentEntries(alice.userEmail, [ + entry("page", { databaseId: "db", viewId: "removed" }), + entry("page", { databaseId: "deleted-db", viewId: "default" }), + ]), + ).toEqual([ + { + ...entry("page", { databaseId: "db", viewId: "removed" }), + target: { documentId: "page", databaseId: "db", viewId: "default" }, + title: "Database page", + icon: null, + viewName: "Default", + fallback: { + reason: "saved_view_unavailable", + requestedViewId: "removed", + }, + }, + ]); + }); + + it("prefers a valid personal active View for a removed recent View", async () => { + stored.set( + settingId(alice.userEmail, "content-database-personal-view:db"), + { + version: 2, + activeViewId: "board", + views: [], + }, + ); + rowsOnce([{ id: "page", title: "Database", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "page", + viewConfigJson: JSON.stringify({ + activeViewId: "table", + views: [ + { id: "table", name: "Table" }, + { id: "board", name: "Board" }, + ], + }), + }, + ]); + const [result] = await resolveContentRecentEntries(alice.userEmail, [ + entry("page", { databaseId: "db", viewId: "removed" }), + ]); + expect(result.target.viewId).toBe("board"); + expect(result.fallback).toEqual({ + reason: "saved_view_unavailable", + requestedViewId: "removed", + }); + }); + + it("reconciles an accessible database alias to its backing page", async () => { + rowsOnce([{ id: "alias", title: "Alias", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "backing-page", + viewConfigJson: JSON.stringify({ + views: [{ id: "table", name: "Table" }], + }), + }, + ]); + rowsOnce([{ id: "backing-page", title: "Database", icon: null }]); + const [result] = await resolveContentRecentEntries(alice.userEmail, [ + entry("alias", { databaseId: "db", viewId: "table" }), + ]); + expect(result.target).toEqual({ + documentId: "backing-page", + databaseId: "db", + viewId: "table", + }); + expect(result.title).toBe("Database"); + }); + + it("uses the exact View's current label and rejects corrupt View configuration", async () => { + const visit = entry("page", { databaseId: "db", viewId: "board" }); + rowsOnce([{ id: "page", title: "Current page", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "page", + viewConfigJson: JSON.stringify({ + views: [{ id: "board", name: "Current board" }], + }), + }, + ]); + expect(await resolveContentRecentEntries(alice.userEmail, [visit])).toEqual( + [ + { + ...visit, + title: "Current page", + icon: null, + viewName: "Current board", + }, + ], + ); + rowsOnce([{ id: "page", title: "Current page", icon: null }]); + rowsOnce([{ id: "db", documentId: "page", viewConfigJson: "broken" }]); + await expect( + resolveContentRecentEntries(alice.userEmail, [visit]), + ).rejects.toThrow(); + }); +}); + +describe("Recent action persistence", () => { + it("atomically migrates v1 duplicates against the latest setting value", async () => { + const id = settingId(alice.userEmail, contentRecentSettingKey()); + const legacy = { + version: 1, + entries: [ + entry("page", { databaseId: "db", viewId: "table" }), + entry("page", { databaseId: "db", viewId: "board" }), + ], + }; + boundary.getSetting.mockResolvedValueOnce(legacy); + stored.set(id, { + version: 2, + entries: [entry("page", { databaseId: "db", viewId: "board" })], + }); + rowsOnce([{ id: "page", title: "Database", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "page", + viewConfigJson: JSON.stringify({ + views: [{ id: "table", name: "Table" }], + }), + }, + ]); + const result = await getRecent.run({}, alice); + expect(result.entries).toHaveLength(1); + expect(stored.get(id)).toMatchObject({ + version: 2, + entries: [{ target: { databaseId: "db", viewId: "board" } }], + }); + }); + + it("records a newly created database's implicit default View", async () => { + rowsOnce([{ id: "page", title: "New database", icon: null }]); + rowsOnce([{ id: "db", documentId: "page", viewConfigJson: "{}" }]); + const target = { documentId: "page", databaseId: "db", viewId: "default" }; + expect(await recordVisit.run(target, alice)).toEqual({ recorded: true }); + expect( + stored.get(settingId(alice.userEmail, contentRecentSettingKey())), + ).toMatchObject({ + entries: [{ target }], + }); + }); + + it("isolates both user and current-context settings", async () => { + boundary.orgId = "org-a"; + rowsOnce([{ id: "page", title: "Page", icon: null }]); + await recordVisit.run({ documentId: "page" }, alice); + expect(boundary.mutateSetting).toHaveBeenCalledWith( + alice.userEmail, + 'content-recent:"org-a"', + expect.any(Function), + ); + expect(await getRecent.run({}, bob)).toEqual({ + entries: [], + scopeKey: JSON.stringify([bob.userEmail, "org-a", null]), + }); + boundary.orgId = "org-b"; + expect(await getRecent.run({}, alice)).toEqual({ + entries: [], + scopeKey: JSON.stringify([alice.userEmail, "org-b", null]), + }); + expect(contentRecentSettingKey()).toBe('content-recent:"org-b"'); + }); + + it("rejects a stale user or org scope before reading any saved navigation", async () => { + boundary.orgId = "current-org"; + for (const scopeKey of [ + JSON.stringify([bob.userEmail, "current-org"]), + JSON.stringify([alice.userEmail, "previous-org"]), + ]) { + await expect(getRecent.run({ scopeKey }, alice)).rejects.toThrow( + "Navigation context changed", + ); + } + expect(boundary.getSetting).not.toHaveBeenCalled(); + expect(boundary.select).not.toHaveBeenCalled(); + }); + + it("accepts and returns the normalized identity scope", async () => { + const scopeKey = JSON.stringify([alice.userEmail, null, null]); + expect( + await getRecent.run({ scopeKey }, { userEmail: " Alice@Example.Test " }), + ).toEqual({ scopeKey, entries: [] }); + }); + + it("preserves concurrent visits using the mutation callback's current value", async () => { + rowsOnce([{ id: "first", title: "First", icon: null }]); + rowsOnce([{ id: "second", title: "Second", icon: null }]); + await Promise.all([ + recordVisit.run({ documentId: "first" }, alice), + recordVisit.run({ documentId: "second" }, alice), + ]); + expect(boundary.getSetting).not.toHaveBeenCalled(); + const state = stored.get( + settingId(alice.userEmail, contentRecentSettingKey()), + ); + expect(state).toMatchObject({ + entries: expect.arrayContaining([ + expect.objectContaining({ target: { documentId: "first" } }), + expect.objectContaining({ target: { documentId: "second" } }), + ]), + }); + }); + + it("refuses unavailable visits before writing", async () => { + rowsOnce([]); + await expect( + recordVisit.run({ documentId: "revoked" }, alice), + ).rejects.toThrow("unavailable"); + expect(boundary.mutateSetting).not.toHaveBeenCalled(); + }); + + it("does not replace corrupt Recent state with a successful new visit", async () => { + const id = settingId(alice.userEmail, contentRecentSettingKey()); + stored.set(id, { version: 99, entries: [] }); + rowsOnce([{ id: "page", title: "Page", icon: null }]); + await expect( + recordVisit.run({ documentId: "page" }, alice), + ).rejects.toThrow(); + expect(stored.get(id)).toEqual({ version: 99, entries: [] }); + }); + + it("propagates persisted corruption, setting read failures, and target read failures", async () => { + stored.set(settingId(alice.userEmail, contentRecentSettingKey()), { + version: 1, + entries: "bad", + }); + await expect(getRecent.run({}, alice)).rejects.toThrow(); + boundary.getSetting.mockRejectedValueOnce(new Error("settings offline")); + await expect(getRecent.run({}, alice)).rejects.toThrow("settings offline"); + boundary.select.mockImplementationOnce(() => { + throw new Error("database offline"); + }); + await expect( + recordVisit.run({ documentId: "page" }, alice), + ).rejects.toThrow("database offline"); + expect(boundary.mutateSetting).not.toHaveBeenCalled(); + }); +}); + +describe("sidebar partial state persistence", () => { + it("merges simultaneous expansion and section edits without overwriting either", async () => { + const sections = defaultContentSidebarSections(); + sections.recent.visible = false; + await Promise.all([ + updateSidebar.run({ version: 2, expandedDocumentIds: ["page"] }, alice), + updateSidebar.run({ version: 2, sections }, alice), + ]); + expect( + stored.get(settingId(alice.userEmail, "content-sidebar-state")), + ).toEqual({ + version: 2, + expandedDocumentIds: ["page"], + sections, + }); + expect(stored.has(settingId(bob.userEmail, "content-sidebar-state"))).toBe( + false, + ); + }); + + it("does not overwrite corrupt persisted state or report a failed mutation as saved", async () => { + const id = settingId(alice.userEmail, "content-sidebar-state"); + stored.set(id, { version: 99 }); + await expect(updateSidebar.run({ version: 2 }, alice)).rejects.toThrow(); + expect(stored.get(id)).toEqual({ version: 99 }); + boundary.mutateSetting.mockRejectedValueOnce(new Error("write failed")); + await expect(updateSidebar.run({ version: 2 }, alice)).rejects.toThrow( + "write failed", + ); + }); +}); diff --git a/templates/content/actions/content-sidebar-state.test.ts b/templates/content/actions/content-sidebar-state.test.ts index 38f10160d17..d0920f362d6 100644 --- a/templates/content/actions/content-sidebar-state.test.ts +++ b/templates/content/actions/content-sidebar-state.test.ts @@ -1,4 +1,26 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const settings = vi.hoisted(() => ({ values: new Map() })); +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: async (_email: string, key: string) => + settings.values.get(key) ?? null, + mutateUserSetting: async ( + _email: string, + key: string, + update: (value: unknown) => unknown, + ) => { + const value = update(settings.values.get(key) ?? null); + settings.values.set(key, value); + return value; + }, +})); +import { defaultContentSidebarSections } from "../shared/content-personal-navigation"; +import getSidebar from "./get-content-sidebar-state"; +import updateSidebar from "./update-content-sidebar-state"; + +beforeEach(() => { + settings.values.clear(); +}); import { CONTENT_SIDEBAR_STATE_VERSION, @@ -6,6 +28,35 @@ import { } from "./_content-sidebar-state"; describe("normalizeContentSidebarState", () => { + it("keeps expansion preferences absent through a first section-only save and read", async () => { + const sections = defaultContentSidebarSections(); + sections.recent.visible = false; + const state = { version: 2 as const, sections }; + const ctx = { userEmail: "sidebar@example.test" }; + expect(await updateSidebar.run(state, ctx)).toEqual({ state }); + expect(await getSidebar.run({}, ctx)).toEqual({ state }); + const saved = settings.values.get("content-sidebar-state"); + expect(saved).not.toHaveProperty("expandedWorkspaceIds"); + expect(saved).not.toHaveProperty("expandedDocumentIds"); + }); + + it("preserves explicit empty expansion preferences when sections change", async () => { + const ctx = { userEmail: "sidebar@example.test" }; + await updateSidebar.run( + { version: 2, expandedWorkspaceIds: [], expandedDocumentIds: [] }, + ctx, + ); + const sections = defaultContentSidebarSections(); + await updateSidebar.run({ version: 2, sections }, ctx); + expect(await getSidebar.run({}, ctx)).toEqual({ + state: { + version: 2, + expandedWorkspaceIds: [], + expandedDocumentIds: [], + sections, + }, + }); + }); it("deduplicates persisted expansion ids", () => { expect( normalizeContentSidebarState({ @@ -20,7 +71,132 @@ describe("normalizeContentSidebarState", () => { }); }); - it("ignores unknown or stale stored values", () => { - expect(normalizeContentSidebarState({ version: 0 })).toBeNull(); + it("distinguishes absent state from unreadable saved state", () => { + expect(normalizeContentSidebarState(null)).toBeNull(); + expect(() => normalizeContentSidebarState({ version: 0 })).toThrow(); + }); + + it("migrates workspaces to recoverable Files and discards saved limits", () => { + expect( + normalizeContentSidebarState( + { + version: 1, + expandedWorkspaceIds: [], + sections: { + order: ["recent", "workspaces", "pinned"], + pinned: { visible: false, expanded: false, limit: 45 }, + recent: { visible: true, expanded: false, limit: 30 }, + }, + }, + "other-space", + ), + ).toEqual({ + version: 2, + expandedWorkspaceIds: [], + sections: { + order: ["recent", "files", "pinned"], + pinned: { visible: false, expanded: false }, + recent: { visible: true, expanded: false }, + files: { visible: true, expanded: false }, + }, + }); + }); + + it("inherits Files expansion from the selected v1 workspace", () => { + const legacy = { + version: 1, + expandedWorkspaceIds: ["space-a"], + sections: { + order: ["pinned", "recent", "workspaces"], + pinned: { visible: true, expanded: true, limit: 5 }, + recent: { visible: true, expanded: true, limit: 5 }, + }, + }; + expect( + normalizeContentSidebarState(legacy, "space-a")?.sections?.files, + ).toEqual({ visible: true, expanded: true }); + expect( + normalizeContentSidebarState(legacy, "space-b")?.sections?.files, + ).toEqual({ visible: true, expanded: false }); + }); + + it("uses the selected space when the first v2 partial write migrates v1", async () => { + settings.values.set("content-sidebar-state", { + version: 1, + expandedWorkspaceIds: ["space-a"], + sections: { + order: ["pinned", "recent", "workspaces"], + pinned: { visible: true, expanded: true, limit: 25 }, + recent: { visible: true, expanded: true, limit: 15 }, + }, + }); + await updateSidebar.run( + { + version: 2, + spaceId: "space-b", + expandedDocumentIds: ["child"], + }, + { userEmail: "sidebar@example.test" }, + ); + const saved = settings.values.get("content-sidebar-state:space-b"); + expect(saved).toMatchObject({ + version: 2, + expandedWorkspaceIds: ["space-a"], + expandedDocumentIds: ["child"], + sections: { files: { visible: true, expanded: false } }, + }); + expect(saved).not.toHaveProperty("spaceId"); + expect(saved).toMatchObject({ sections: { pinned: { visible: true } } }); + }); + + it("keeps section preferences isolated between Content spaces", async () => { + const ctx = { userEmail: "sidebar@example.test" }; + const first = defaultContentSidebarSections(); + first.pinned.visible = false; + const second = defaultContentSidebarSections(); + second.recent.visible = false; + + await updateSidebar.run( + { version: 2, spaceId: "space-a", sections: first }, + ctx, + ); + await updateSidebar.run( + { version: 2, spaceId: "space-b", sections: second }, + ctx, + ); + + expect(await getSidebar.run({ spaceId: "space-a" }, ctx)).toEqual({ + state: { version: 2, sections: first }, + }); + expect(await getSidebar.run({ spaceId: "space-b" }, ctx)).toEqual({ + state: { version: 2, sections: second }, + }); + }); + + it("merges an automatic section patch into the latest workspace state", async () => { + const ctx = { userEmail: "sidebar@example.test" }; + const sections = defaultContentSidebarSections(); + sections.order = ["recent", "files", "pinned"]; + sections.pinned.visible = false; + await updateSidebar.run({ version: 2, spaceId: "space-a", sections }, ctx); + + await updateSidebar.run( + { + version: 2, + spaceId: "space-a", + sectionsPatch: { pinned: { visible: true, expanded: true } }, + }, + ctx, + ); + + expect(await getSidebar.run({ spaceId: "space-a" }, ctx)).toEqual({ + state: { + version: 2, + sections: { + ...sections, + pinned: { visible: true, expanded: true }, + }, + }, + }); }); }); diff --git a/templates/content/actions/delete-content-database.ts b/templates/content/actions/delete-content-database.ts index 9af5bfb21e4..e2fcfacbc02 100644 --- a/templates/content/actions/delete-content-database.ts +++ b/templates/content/actions/delete-content-database.ts @@ -28,6 +28,12 @@ export default defineAction({ z .object({ databaseId: z.string().describe("Content collection ID"), + activeDocumentId: z + .string() + .optional() + .describe( + "Currently open document, used only to return an explicit navigation outcome.", + ), }) .strict(), ]), @@ -98,7 +104,7 @@ export default defineAction({ await refreshAfterSetup(result.receipt); return { success: true, ...result.value, receipt: result.receipt }; } - const { databaseId } = args; + const { databaseId, activeDocumentId } = args; const { database } = await assertContentDatabaseLifecycleAccess(databaseId); if (database.systemRole) { throw new Error("System Content databases cannot be deleted"); @@ -106,7 +112,7 @@ export default defineAction({ await assertAccess("document", database.documentId, "admin"); const db = getDb(); const deletedAt = database.deletedAt ?? new Date().toISOString(); - await db.transaction(async (tx) => { + const deletedDocumentIds = await db.transaction(async (tx) => { const transactionDb = tx as unknown as ReturnType; const lockedDatabaseIds = await lockDatabasesForTrash( transactionDb, @@ -129,6 +135,13 @@ export default defineAction({ databaseId, documentId: database.documentId, deletedAt, + activeTargetDeleted: activeDocumentId + ? deletedDocumentIds.includes(activeDocumentId) + : false, + navigationPath: + activeDocumentId && deletedDocumentIds.includes(activeDocumentId) + ? "/home" + : null, }; }, }); diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 00d74bf1d51..62198ef336f 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -1166,6 +1166,12 @@ export default defineAction({ .string() .optional() .describe("Collection page the deletion was initiated from"), + activeDocumentId: z + .string() + .optional() + .describe( + "Currently open document, used only to return an explicit navigation outcome.", + ), }), run: async (args, ctx) => { const id = args.id; @@ -1246,6 +1252,16 @@ export default defineAction({ await writeAppState("refresh-signal", { ts: Date.now() }); - return { success: true, deleted: deleted.length }; + return { + success: true, + deleted: deleted.length, + activeTargetDeleted: args.activeDocumentId + ? deleted.includes(args.activeDocumentId) + : false, + navigationPath: + args.activeDocumentId && deleted.includes(args.activeDocumentId) + ? "/home" + : null, + }; }, }); diff --git a/templates/content/actions/get-content-database.favorites-order.db.test.ts b/templates/content/actions/get-content-database.favorites-order.db.test.ts new file mode 100644 index 00000000000..82eef06b670 --- /dev/null +++ b/templates/content/actions/get-content-database.favorites-order.db.test.ts @@ -0,0 +1,259 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { putUserSetting } from "@agent-native/core/settings"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const TEST_DB_PATH = join( + tmpdir(), + `content-favorites-order-${process.pid}-${Date.now()}.pglite`, +); +const OWNER = "favorites-order-owner@example.com"; + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let getContentDatabase: typeof import("./get-content-database.js").default; +let personalContentSpaceId: typeof import("./_content-spaces.js").personalContentSpaceId; +let personalDatabaseViewSettingKey: typeof import("./_content-database-personal-view.js").personalDatabaseViewSettingKey; + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + const spaces = await import("./_content-spaces.js"); + personalContentSpaceId = spaces.personalContentSpaceId; + ({ personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js")); + getContentDatabase = (await import("./get-content-database.js")).default; + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as any); + await runWithRequestContext({ userEmail: OWNER }, () => + spaces.provisionContentSpaces(getDb(), OWNER), + ); +}, 60_000); + +afterAll(() => { + rmSync(TEST_DB_PATH, { force: true, recursive: true }); +}); + +describe("scoped Favorites personal ordering", () => { + it("orders accessible selected-space pins before applying the display bound", async () => { + const db = getDb(); + const spaceId = personalContentSpaceId(OWNER); + const databases = await db + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.spaceId, spaceId)); + const favorites = databases.find( + (database: { systemRole: string | null }) => + database.systemRole === "favorites", + ); + const files = databases.find( + (database: { systemRole: string | null }) => + database.systemRole === "files", + ); + if (!favorites || !files) + throw new Error("Missing personal system databases"); + + const now = "2026-09-17T12:00:00.000Z"; + const scopedDocuments = Array.from({ length: 54 }, (_, index) => ({ + id: `favorites-order-document-${String(index).padStart(2, "0")}`, + ownerEmail: OWNER, + orgId: null, + spaceId, + parentId: null, + title: + index < 2 ? "Same title" : `Title ${String(index).padStart(2, "0")}`, + content: "", + description: "", + position: index, + isFavorite: 1, + hideFromSearch: 0, + visibility: "private", + createdAt: now, + updatedAt: now, + })); + const inaccessibleDocument = { + ...scopedDocuments[0], + id: "favorites-order-inaccessible", + ownerEmail: "someone-else@example.com", + title: "Inaccessible", + }; + const unscopedDocument = { + ...scopedDocuments[0], + id: "favorites-order-unscoped", + spaceId: null, + title: "Unscoped", + }; + await db + .insert(schema.documents) + .values([...scopedDocuments, inaccessibleDocument, unscopedDocument]); + + const favoriteItems = [ + ...scopedDocuments.map((document, index) => ({ + id: + index === 0 + ? "favorites-order-item-a" + : index === 1 + ? "favorites-order-item-b" + : `favorites-order-item-${String(index).padStart(2, "0")}`, + ownerEmail: OWNER, + databaseId: favorites.id, + documentId: document.id, + position: index < 2 ? 0 : index, + createdAt: now, + updatedAt: now, + })), + { + id: "favorites-order-item-inaccessible", + ownerEmail: OWNER, + databaseId: favorites.id, + documentId: inaccessibleDocument.id, + position: 0, + createdAt: now, + updatedAt: now, + }, + { + id: "favorites-order-item-unscoped", + ownerEmail: OWNER, + databaseId: favorites.id, + documentId: unscopedDocument.id, + position: 0, + createdAt: now, + updatedAt: now, + }, + ]; + await db.insert(schema.contentDatabaseItems).values([ + ...favoriteItems, + ...[...scopedDocuments, inaccessibleDocument].map((document, index) => ({ + id: `favorites-order-files-item-${String(index).padStart(2, "0")}`, + ownerEmail: OWNER, + databaseId: files.id, + documentId: document.id, + position: index, + createdAt: now, + updatedAt: now, + })), + ]); + + const viewConfig = JSON.parse(favorites.viewConfigJson); + const activeViewId = viewConfig.activeViewId ?? viewConfig.views[0].id; + const customFirst = favoriteItems[53].id; + await putUserSetting(OWNER, personalDatabaseViewSettingKey(favorites.id), { + version: 2, + activeViewId, + views: [ + { + id: activeViewId, + sorts: [], + filters: [], + filterMode: "and", + sidebarOrder: { mode: "custom", itemIds: [customFirst] }, + }, + ], + }); + + const { + contentDatabaseCustomOrderRank, + contentDatabaseFilesMembershipFilter, + } = await import("./_database-utils.js"); + const scopedQueryShape = db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, favorites.id), + contentDatabaseFilesMembershipFilter(files.id), + ), + ) + .limit(50) + .toSQL(); + expect(scopedQueryShape.sql).toContain( + 'exists (select "id" from "content_database_items" "scoped_files_memberships"', + ); + expect(scopedQueryShape.sql).toContain( + '"scoped_files_memberships"."document_id" = "content_database_items"."document_id"', + ); + expect(scopedQueryShape.sql).toMatch(/\)\) limit \$3$/); + expect(scopedQueryShape.params).toEqual([favorites.id, files.id, 50]); + + const boundedCustomIds = Array.from( + { length: 5_000 }, + (_, index) => `stored-item-${index}`, + ); + const customOrderQueryShape = db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, favorites.id)) + .orderBy(contentDatabaseCustomOrderRank(boundedCustomIds)) + .limit(50) + .toSQL(); + expect(customOrderQueryShape.params).toEqual([ + favorites.id, + JSON.stringify(boundedCustomIds), + 5_001, + 50, + ]); + + const scoped = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabase.run( + { databaseId: favorites.id, contentSpaceId: spaceId, limit: 50 }, + { userEmail: OWNER } as any, + ), + ); + expect(scoped.items).toHaveLength(50); + expect(scoped.items.slice(0, 3).map((item) => item.id)).toEqual([ + customFirst, + "favorites-order-item-a", + "favorites-order-item-b", + ]); + expect(scoped.items.map((item) => item.id)).not.toContain( + "favorites-order-item-inaccessible", + ); + expect(scoped.items.map((item) => item.id)).not.toContain( + "favorites-order-item-unscoped", + ); + expect(scoped.pagination).toMatchObject({ totalItems: 54, hasMore: true }); + + await putUserSetting(OWNER, personalDatabaseViewSettingKey(favorites.id), { + version: 2, + activeViewId, + views: [ + { + id: activeViewId, + sorts: [], + filters: [], + filterMode: "and", + sidebarOrder: { mode: "name", itemIds: [customFirst] }, + }, + ], + }); + const byName = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabase.run( + { databaseId: favorites.id, contentSpaceId: spaceId, limit: 2 }, + { userEmail: OWNER } as any, + ), + ); + expect(byName.items.map((item) => item.id)).toEqual([ + "favorites-order-item-a", + "favorites-order-item-b", + ]); + + const aggregate = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabase.run({ databaseId: favorites.id, limit: 100 }, { + userEmail: OWNER, + } as any), + ); + expect(aggregate.items.map((item) => item.id)).toContain( + "favorites-order-item-unscoped", + ); + expect(aggregate.items.map((item) => item.id)).not.toContain( + "favorites-order-item-inaccessible", + ); + }); +}); diff --git a/templates/content/actions/get-content-database.ts b/templates/content/actions/get-content-database.ts index c17e3550220..a4bba2450c7 100644 --- a/templates/content/actions/get-content-database.ts +++ b/templates/content/actions/get-content-database.ts @@ -1,16 +1,21 @@ import { defineAction, type ActionRunContext } from "@agent-native/core/action"; import { z } from "zod"; +import { getDb } from "../server/db/index.js"; import type { ContentDatabaseResponse, ContentDatabaseUnavailableResponse, + ContentSidebarViewOrder, } from "../shared/api.js"; +import { readPersonalDatabaseViewOverrides } from "./_content-database-personal-view.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { CONTENT_DATABASE_MAX_READ_LIMIT, contentDatabaseTableQuerySchema, getContentDatabaseResponse, resolveContentDatabaseRead, } from "./_database-utils.js"; +import { parseDatabaseViewConfig } from "./_property-utils.js"; const getContentDatabaseSchema = z.object({ databaseId: z.string().optional().describe("Collection ID"), @@ -22,6 +27,14 @@ const getContentDatabaseSchema = z.object({ .max(CONTENT_DATABASE_MAX_READ_LIMIT) .optional(), offset: z.coerce.number().int().min(0).optional(), + contentSpaceId: z + .string() + .min(1) + .max(256) + .optional() + .describe( + "For the personal Favorites database only, filter rows to authoritative Files membership in this Content space before pagination.", + ), tableQuery: contentDatabaseTableQuerySchema, }); @@ -49,7 +62,7 @@ export default defineAction({ http: { method: "GET" }, readOnly: true, run: async ( - { databaseId, documentId, limit, offset, tableQuery }, + { databaseId, documentId, limit, offset, tableQuery, contentSpaceId }, context, ): Promise => { const resolved = await resolveContentDatabaseRead({ @@ -58,11 +71,42 @@ export default defineAction({ }); if (!resolved.available) return resolved; + let filesMembershipDatabaseId: string | undefined; + let sidebarOrder: ContentSidebarViewOrder | undefined; + if (contentSpaceId) { + if (resolved.database.systemRole !== "favorites") { + throw new Error("Content-space scope is only valid for Favorites."); + } + const db = getDb(); + const access = await resolveContentSpaceAccess(contentSpaceId, "viewer", { + db, + }); + filesMembershipDatabaseId = access.space.filesDatabaseId; + if (context?.userEmail) { + const sharedConfig = parseDatabaseViewConfig( + resolved.database.viewConfigJson, + ); + const overrides = await readPersonalDatabaseViewOverrides( + context.userEmail, + resolved.database.id, + ); + const activeViewId = + overrides?.activeViewId && + sharedConfig.views.some((view) => view.id === overrides.activeViewId) + ? overrides.activeViewId + : sharedConfig.activeViewId; + sidebarOrder = overrides?.views.find((view) => view.id === activeViewId) + ?.sidebarOrder ?? { mode: "custom", itemIds: [] }; + } + } + return getContentDatabaseResponse(resolved.database.id, { limit: resolveContentDatabaseReadLimit(limit, context?.caller), offset, tableQuery, database: resolved.database, + filesMembershipDatabaseId, + sidebarOrder, }); }, }); diff --git a/templates/content/actions/get-content-navigation-context.ts b/templates/content/actions/get-content-navigation-context.ts new file mode 100644 index 00000000000..f72ff0dc558 --- /dev/null +++ b/templates/content/actions/get-content-navigation-context.ts @@ -0,0 +1,399 @@ +import { ActionContractError } from "@agent-native/core"; +import { defineAction, fail } from "@agent-native/core/action"; +import { alias } from "@agent-native/core/db/schema"; +import { + getRequestOrgId, + getRequestUserEmail, +} from "@agent-native/core/server/request-context"; +import { accessFilter, type AccessContext } from "@agent-native/core/sharing"; +import { and, eq, isNull, or } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import type { ContentNavigationContext } from "../shared/api.js"; +import { favoriteDocumentIds } from "./_content-favorites.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { serializeDocumentSource } from "./_document-source.js"; +import { + getContentSourceMode, + getLocalFileDocument, + getLocalDocumentContextPath, + isLocalDocumentId, +} from "./_local-file-documents.js"; + +const MAX_ANCESTORS = 100; + +type NavigationFilesContext = { + databaseId: string; + databaseDocumentId: string; + spaceId: string; + accessContext: AccessContext; +}; + +function isContentSpaceAccessDenial(error: unknown): boolean { + return ( + error instanceof ActionContractError && + ["FORBIDDEN", "SPACE_NOT_FOUND"].includes(error.errorCode) + ); +} + +async function resolveNavigationFilesContext( + db: ReturnType, + documentId: string, +): Promise { + const membershipCandidates = await db + .select({ + databaseId: schema.contentDatabases.id, + databaseDocumentId: schema.contentDatabases.documentId, + spaceId: schema.contentSpaces.id, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.contentDatabases, + eq(schema.contentDatabases.id, schema.contentDatabaseItems.databaseId), + ) + .innerJoin( + schema.contentSpaces, + eq(schema.contentSpaces.id, schema.contentDatabases.spaceId), + ) + .where( + and( + eq(schema.contentDatabaseItems.documentId, documentId), + eq(schema.contentDatabases.systemRole, "files"), + eq(schema.contentSpaces.filesDatabaseId, schema.contentDatabases.id), + isNull(schema.contentDatabases.deletedAt), + isNull(schema.contentSpaces.archivedAt), + ), + ); + const filesDocumentCandidates = await db + .select({ + databaseId: schema.contentDatabases.id, + databaseDocumentId: schema.contentDatabases.documentId, + spaceId: schema.contentSpaces.id, + }) + .from(schema.contentDatabases) + .innerJoin( + schema.contentSpaces, + eq(schema.contentSpaces.id, schema.contentDatabases.spaceId), + ) + .where( + and( + eq(schema.contentDatabases.documentId, documentId), + eq(schema.contentDatabases.systemRole, "files"), + eq(schema.contentSpaces.filesDatabaseId, schema.contentDatabases.id), + isNull(schema.contentDatabases.deletedAt), + isNull(schema.contentSpaces.archivedAt), + ), + ); + const candidates = new Map( + [...membershipCandidates, ...filesDocumentCandidates].map((candidate) => [ + candidate.databaseId, + candidate, + ]), + ); + const authorized: NavigationFilesContext[] = []; + for (const candidate of candidates.values()) { + try { + const access = await resolveContentSpaceAccess( + candidate.spaceId, + "viewer", + { db }, + ); + authorized.push({ + ...candidate, + accessContext: { + userEmail: access.authority.userEmail, + orgId: access.authority.orgId ?? undefined, + }, + }); + } catch (error) { + if (!isContentSpaceAccessDenial(error)) throw error; + } + } + if (authorized.length > 1) { + fail( + "The document belongs to more than one authoritative Files navigation context.", + { + errorCode: "navigation_context_ambiguous", + statusCode: 409, + }, + ); + } + return authorized[0]; +} + +function permissions(role: string) { + return { + accessRole: role as "owner" | "admin" | "editor" | "commenter" | "viewer", + canView: true, + canComment: ["owner", "admin", "editor", "commenter"].includes(role), + canEdit: ["owner", "admin", "editor"].includes(role), + canManage: ["owner", "admin"].includes(role), + }; +} + +export default defineAction({ + description: + "Read bounded metadata needed to reveal and navigate one Content item without loading its body or enumerating documents.", + agentTool: false, + schema: z.object({ id: z.string().min(1) }), + http: { method: "GET" }, + readOnly: true, + run: async ({ id }): Promise => { + const mode = await getContentSourceMode(); + if (mode === "local-files" && isLocalDocumentId(id)) { + const document = await getLocalFileDocument(id); + const contextPath = await getLocalDocumentContextPath(id); + return { + mode, + document: { ...document, content: "" }, + path: [ + ...contextPath.map((entry) => ({ + id: entry.id, + parentId: null, + title: entry.title, + icon: null, + databaseId: null, + databaseDocumentId: null, + isFavorite: false, + visibility: "private" as const, + ...permissions("owner"), + source: document.source, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + })), + { + id: document.id, + parentId: document.parentId, + title: document.title, + icon: document.icon, + databaseId: null, + databaseDocumentId: null, + isFavorite: document.isFavorite, + visibility: document.visibility, + accessRole: document.accessRole, + canView: document.canView, + canComment: document.canComment, + canEdit: document.canEdit, + canManage: document.canManage, + source: document.source, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + }, + ], + workspaceFilesDatabaseId: null, + }; + } + + const db = getDb(); + const databaseDocuments = alias( + schema.documents, + "navigation_context_database_documents", + ); + const path: ContentNavigationContext["path"] = []; + const userEmail = getRequestUserEmail(); + const navigationFilesContext = await resolveNavigationFilesContext(db, id); + const accessContext: AccessContext = + navigationFilesContext?.accessContext ?? { + userEmail, + orgId: getRequestOrgId() ?? undefined, + }; + const orgId = accessContext.orgId; + const seen = new Set(); + let activeRow: typeof schema.documents.$inferSelect | undefined; + let activeMembership: + | { databaseId: string; databaseDocumentId: string } + | undefined; + let currentId: string | null = id; + while (currentId && path.length <= MAX_ANCESTORS) { + if (seen.has(currentId)) + throw new Error("Document ancestry contains a cycle"); + seen.add(currentId); + const [row] = await db + .select({ + id: schema.documents.id, + spaceId: schema.documents.spaceId, + parentId: schema.documents.parentId, + title: schema.documents.title, + icon: schema.documents.icon, + position: schema.documents.position, + description: schema.documents.description, + visibility: schema.documents.visibility, + ownerEmail: schema.documents.ownerEmail, + orgId: schema.documents.orgId, + isFavorite: schema.documents.isFavorite, + hideFromSearch: schema.documents.hideFromSearch, + trashedAt: schema.documents.trashedAt, + trashRootId: schema.documents.trashRootId, + bodyRevision: schema.documents.bodyRevision, + sourceMode: schema.documents.sourceMode, + sourceKind: schema.documents.sourceKind, + sourcePath: schema.documents.sourcePath, + sourceRootPath: schema.documents.sourceRootPath, + sourceUpdatedAt: schema.documents.sourceUpdatedAt, + createdAt: schema.documents.createdAt, + updatedAt: schema.documents.updatedAt, + }) + .from(schema.documents) + .where( + and( + eq(schema.documents.id, currentId), + isNull(schema.documents.trashedAt), + accessFilter( + schema.documents, + schema.documentShares, + accessContext, + ), + ), + ) + .limit(1); + if (!row) { + if (currentId === id) + throw Object.assign(new Error(`Document "${id}" not found`), { + statusCode: 404, + }); + break; + } + const memberships = await db + .select({ + databaseId: schema.contentDatabaseItems.databaseId, + databaseDocumentId: schema.contentDatabases.documentId, + systemRole: schema.contentDatabases.systemRole, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.contentDatabases, + eq( + schema.contentDatabases.id, + schema.contentDatabaseItems.databaseId, + ), + ) + .innerJoin( + schema.contentSpaces, + eq(schema.contentSpaces.id, schema.contentDatabases.spaceId), + ) + .innerJoin( + databaseDocuments, + eq(databaseDocuments.id, schema.contentDatabases.documentId), + ) + .where( + and( + eq(schema.contentDatabaseItems.documentId, row.id), + eq(schema.contentDatabases.spaceId, schema.contentSpaces.id), + eq( + schema.contentSpaces.filesDatabaseId, + schema.contentDatabases.id, + ), + isNull(schema.contentDatabases.deletedAt), + eq(schema.contentDatabases.systemRole, "files"), + isNull(databaseDocuments.trashedAt), + accessFilter( + databaseDocuments, + schema.documentShares, + accessContext, + ), + ), + ) + .limit(2); + if (memberships.length > 1) { + fail( + "The document belongs to more than one authoritative Files navigation context.", + { + errorCode: "navigation_context_ambiguous", + statusCode: 409, + }, + ); + } + const membership = memberships[0]; + const activeFilesDatabase = + row.id === id && + !membership && + navigationFilesContext?.databaseDocumentId === row.id + ? navigationFilesContext + : undefined; + const navigationMembership = membership ?? activeFilesDatabase; + const shareRows = userEmail + ? await db + .select({ role: schema.documentShares.role }) + .from(schema.documentShares) + .where( + and( + eq(schema.documentShares.resourceId, row.id), + or( + and( + eq(schema.documentShares.principalType, "user"), + eq(schema.documentShares.principalId, userEmail), + ), + ...(orgId + ? [ + and( + eq(schema.documentShares.principalType, "org"), + eq(schema.documentShares.principalId, orgId), + ), + ] + : []), + ), + ), + ) + : []; + const role = + userEmail && row.ownerEmail.toLowerCase() === userEmail.toLowerCase() + ? "owner" + : (["admin", "editor", "commenter", "viewer"].find((candidate) => + shareRows.some((share) => share.role === candidate), + ) ?? "viewer"); + path.unshift({ + id: row.id, + parentId: row.parentId, + title: row.title, + icon: row.icon, + databaseId: navigationMembership?.databaseId ?? null, + databaseDocumentId: navigationMembership?.databaseDocumentId ?? null, + isFavorite: false, + visibility: row.visibility as "private" | "org" | "public", + ...permissions(role), + source: serializeDocumentSource(row), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + currentId = row.parentId; + if (path.length > MAX_ANCESTORS) + throw new Error( + "Document ancestry exceeds the supported navigation depth", + ); + if (row.id === id) { + activeRow = row as typeof schema.documents.$inferSelect; + activeMembership = navigationMembership; + } + } + if (userEmail) { + const favorites = await favoriteDocumentIds( + db, + userEmail, + path.map((entry) => entry.id), + ); + for (const entry of path) entry.isFavorite = favorites.has(entry.id); + } + return { + mode, + document: { + id: activeRow!.id, + parentId: activeRow!.parentId, + title: activeRow!.title, + content: "", + description: activeRow!.description, + icon: activeRow!.icon, + position: activeRow!.position, + isFavorite: path[path.length - 1]?.isFavorite ?? false, + hideFromSearch: false, + visibility: activeRow!.visibility, + ...permissions(path[path.length - 1]?.accessRole ?? "viewer"), + source: serializeDocumentSource(activeRow!), + createdAt: activeRow!.createdAt, + updatedAt: activeRow!.updatedAt, + }, + path, + workspaceFilesDatabaseId: activeMembership?.databaseId ?? null, + }; + }, +}); diff --git a/templates/content/actions/get-content-recent.ts b/templates/content/actions/get-content-recent.ts new file mode 100644 index 00000000000..63de569cb2a --- /dev/null +++ b/templates/content/actions/get-content-recent.ts @@ -0,0 +1,58 @@ +import { defineAction, fail } from "@agent-native/core/action"; +import { getRequestOrgId } from "@agent-native/core/server/request-context"; +import { getUserSetting, mutateUserSetting } from "@agent-native/core/settings"; +import { z } from "zod"; + +import { readContentRecentState } from "../shared/content-personal-navigation.js"; +import { + contentRecentSettingKey, + resolveContentRecentEntries, +} from "./_content-recent.js"; + +export default defineAction({ + description: + "Read the current user's recently visited Pages and exact Views in the current context, resolving every target under current access.", + schema: z.object({ + scopeKey: z.string().optional(), + spaceId: z + .string() + .min(1) + .max(256) + .optional() + .describe( + "Optional Content space whose authoritative Files membership scopes the returned destinations.", + ), + }), + http: { method: "GET" }, + run: async (args, ctx) => { + if (!ctx?.userEmail) fail("Not authenticated.", { statusCode: 401 }); + const scopeKey = JSON.stringify([ + ctx.userEmail.trim().toLowerCase(), + getRequestOrgId() ?? null, + args.spaceId ?? null, + ]); + if (args.scopeKey && args.scopeKey !== scopeKey) + fail("Navigation context changed.", { + statusCode: 409, + errorCode: "context_changed", + }); + const settingKey = contentRecentSettingKey(); + const stored = await getUserSetting(ctx.userEmail, settingKey); + let state = readContentRecentState(stored); + if (stored !== null && (stored as { version?: unknown }).version === 1) { + state = readContentRecentState( + await mutateUserSetting(ctx.userEmail, settingKey, (latest) => + readContentRecentState(latest), + ), + ); + } + return { + scopeKey, + entries: await resolveContentRecentEntries( + ctx.userEmail, + state.entries, + args.spaceId, + ), + }; + }, +}); diff --git a/templates/content/actions/get-content-sidebar-state.ts b/templates/content/actions/get-content-sidebar-state.ts index 58703ae1025..141f28322e6 100644 --- a/templates/content/actions/get-content-sidebar-state.ts +++ b/templates/content/actions/get-content-sidebar-state.ts @@ -4,20 +4,25 @@ import { z } from "zod"; import { CONTENT_SIDEBAR_STATE_SETTING_KEY, + contentSidebarStateSettingKey, normalizeContentSidebarState, } from "./_content-sidebar-state.js"; export default defineAction({ description: "Read the current user's Content sidebar expansion state.", - schema: z.object({}), + schema: z.object({ spaceId: z.string().min(1).max(256).optional() }), http: { method: "GET" }, agentTool: false, - run: async (_args, ctx) => { + run: async (args, ctx) => { if (!ctx?.userEmail) throw new Error("Not authenticated."); - const stored = await getUserSetting( - ctx.userEmail, - CONTENT_SIDEBAR_STATE_SETTING_KEY, - ); - return { state: normalizeContentSidebarState(stored) }; + const scopedKey = contentSidebarStateSettingKey(args.spaceId); + const stored = await getUserSetting(ctx.userEmail, scopedKey); + const legacy = + stored === null && scopedKey !== CONTENT_SIDEBAR_STATE_SETTING_KEY + ? await getUserSetting(ctx.userEmail, CONTENT_SIDEBAR_STATE_SETTING_KEY) + : null; + return { + state: normalizeContentSidebarState(stored ?? legacy, args.spaceId), + }; }, }); diff --git a/templates/content/actions/get-document.ts b/templates/content/actions/get-document.ts index e0dab85d958..f271218ec41 100644 --- a/templates/content/actions/get-document.ts +++ b/templates/content/actions/get-document.ts @@ -222,6 +222,7 @@ export default defineAction({ return { id: doc.id, + spaceId: doc.spaceId, deepLink: buildDeepLink({ app: "content", view: "editor", diff --git a/templates/content/actions/list-content-spaces.ts b/templates/content/actions/list-content-spaces.ts index d46a273e354..1bc95d3ac19 100644 --- a/templates/content/actions/list-content-spaces.ts +++ b/templates/content/actions/list-content-spaces.ts @@ -14,6 +14,7 @@ import { personalContentSpaceId, systemIdsForContentSpace, } from "./_content-spaces.js"; +import { getContentSourceMode } from "./_local-file-documents.js"; export default defineAction({ description: @@ -30,7 +31,10 @@ export default defineAction({ const personalSpaceId = personalContentSpaceId(email); const catalogIds = systemIdsForContentSpace(personalSpaceId, "workspaces"); const favoritesIds = systemIdsForContentSpace(personalSpaceId, "favorites"); - const memberships = await listContentOrganizationMemberships(email); + const [memberships, sourceMode] = await Promise.all([ + listContentOrganizationMemberships(email), + getContentSourceMode(), + ]); const roleByOrgId = new Map( memberships.map((membership) => [ membership.orgId, @@ -153,6 +157,7 @@ export default defineAction({ ) .digest("hex"); return { + sourceMode, catalogDatabaseId: catalogIds.databaseId, catalogDocumentId: catalogIds.documentId, favoritesDatabaseId: filesDocumentIdByDatabaseId.has( diff --git a/templates/content/actions/query-content-database-items.navigation.db.test.ts b/templates/content/actions/query-content-database-items.navigation.db.test.ts new file mode 100644 index 00000000000..f31fe3eec33 --- /dev/null +++ b/templates/content/actions/query-content-database-items.navigation.db.test.ts @@ -0,0 +1,1155 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { getDbExec } from "@agent-native/core/db"; +import { runWithRequestContext } from "@agent-native/core/server"; +import { putUserSetting } from "@agent-native/core/settings"; +import { eq, inArray } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION } from "../shared/api.js"; + +const TEST_DB_PATH = join( + tmpdir(), + `content-files-navigation-${process.pid}-${Date.now()}.pglite`, +); +const OWNER = "navigation-owner@example.com"; +const OTHER = "navigation-other@example.com"; +const SPACE_ID = "navigation-space"; +const DATABASE_ID = "navigation-files"; +const ORGANIZATION_MEMBER = "navigation-member@example.com"; +const ORGANIZATION_OTHER = "navigation-org-owner@example.com"; +const ORGANIZATION_DENIED = "navigation-denied@example.com"; +const ORGANIZATION_A_ID = "navigation-org-a"; +const ORGANIZATION_B_ID = "navigation-org-b"; +const ORGANIZATION_SPACE_ID = "navigation-org-space"; +const ORGANIZATION_DATABASE_ID = "navigation-org-files"; +const ORGANIZATION_FILES_DOCUMENT_ID = "navigation-org-files-document"; + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let action: typeof import("./query-content-database-items.js").default; +let navigationContextAction: typeof import("./get-content-navigation-context.js").default; + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + action = (await import("./query-content-database-items.js")).default; + navigationContextAction = ( + await import("./get-content-navigation-context.js") + ).default; + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as any); + await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT NOT NULL, created_at BIGINT NOT NULL, + identity_authority TEXT, identity_id TEXT + )`); + await getDbExec().execute(`CREATE TABLE IF NOT EXISTS org_members ( + id TEXT PRIMARY KEY, org_id TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, joined_at BIGINT NOT NULL, + federation_removal_pending_at BIGINT + )`); + + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: "navigation-files-document", + spaceId: SPACE_ID, + ownerEmail: OWNER, + title: "Files", + content: "database body must not be read", + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentSpaces).values({ + id: SPACE_ID, + name: "Navigation", + kind: "personal", + ownerEmail: OWNER, + filesDatabaseId: DATABASE_ID, + createdBy: OWNER, + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: DATABASE_ID, + spaceId: SPACE_ID, + ownerEmail: OWNER, + documentId: "navigation-files-document", + title: "Files", + systemRole: "files", + createdAt: now, + updatedAt: now, + }); + + for (const [id, name] of [ + [ORGANIZATION_A_ID, "Navigation Org A"], + [ORGANIZATION_B_ID, "Navigation Org B"], + ]) { + await getDbExec().execute({ + sql: "INSERT INTO organizations (id, name, created_by, created_at) VALUES ($1, $2, $3, $4)", + args: [id, name, ORGANIZATION_OTHER, Date.now()], + }); + } + for (const orgId of [ORGANIZATION_A_ID, ORGANIZATION_B_ID]) { + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES ($1, $2, $3, $4, $5)", + args: [ + `navigation-member-${orgId}`, + orgId, + ORGANIZATION_MEMBER, + "member", + Date.now(), + ], + }); + } + await getDb().insert(schema.documents).values({ + id: ORGANIZATION_FILES_DOCUMENT_ID, + spaceId: ORGANIZATION_SPACE_ID, + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + title: "Organization Files", + content: "", + visibility: "org", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentSpaces).values({ + id: ORGANIZATION_SPACE_ID, + name: "Organization Navigation", + kind: "organization", + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + filesDatabaseId: ORGANIZATION_DATABASE_ID, + createdBy: ORGANIZATION_OTHER, + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: ORGANIZATION_DATABASE_ID, + spaceId: ORGANIZATION_SPACE_ID, + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + documentId: ORGANIZATION_FILES_DOCUMENT_ID, + title: "Organization Files", + systemRole: "files", + createdAt: now, + updatedAt: now, + }); +}, 60_000); + +afterAll(() => { + rmSync(TEST_DB_PATH, { force: true, recursive: true }); +}); + +async function addFile(args: { + id: string; + parentId?: string | null; + ownerEmail?: string; + position?: number; + title?: string; + createdAt?: string; + updatedAt?: string; + spaceId?: string | null; +}) { + const timestamp = args.createdAt ?? "2026-01-01T00:00:00.000Z"; + await getDb() + .insert(schema.documents) + .values({ + id: args.id, + spaceId: args.spaceId === undefined ? SPACE_ID : args.spaceId, + parentId: args.parentId ?? null, + ownerEmail: args.ownerEmail ?? OWNER, + title: args.title ?? args.id, + content: `heavy body ${args.id}`, + icon: "file", + visibility: "private", + createdAt: timestamp, + updatedAt: args.updatedAt ?? timestamp, + }); + await getDb() + .insert(schema.contentDatabaseItems) + .values({ + id: `membership-${args.id}`, + ownerEmail: OWNER, + databaseId: DATABASE_ID, + documentId: args.id, + position: args.position ?? 0, + createdAt: timestamp, + updatedAt: args.updatedAt ?? timestamp, + }); +} + +async function addOrganizationFile(args: { + id: string; + parentId?: string | null; + visibility?: "private" | "org"; +}) { + const now = new Date().toISOString(); + await getDb() + .insert(schema.documents) + .values({ + id: args.id, + spaceId: ORGANIZATION_SPACE_ID, + parentId: args.parentId ?? null, + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + title: args.id, + content: "", + visibility: args.visibility ?? "org", + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.contentDatabaseItems) + .values({ + id: `membership-${args.id}`, + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + databaseId: ORGANIZATION_DATABASE_ID, + documentId: args.id, + position: 0, + createdAt: now, + updatedAt: now, + }); +} + +async function navigate( + navigation: { + parentId: string | null; + sort?: "custom" | "name" | "created" | "last_edited"; + viewId?: string; + cursor?: string; + }, + limit?: number, +) { + return runWithRequestContext({ userEmail: OWNER }, () => + action.run({ databaseId: DATABASE_ID, navigation, limit }, { + userEmail: OWNER, + } as any), + ); +} + +describe("query-content-database-items Files navigation", () => { + it.each([50, 100, 500])( + "keeps %i roots bounded to stable 20-row pages", + async (rootCount) => { + const prefix = `bounded-${rootCount}`; + const ids = Array.from( + { length: rootCount }, + (_, index) => `${prefix}-${String(index).padStart(3, "0")}`, + ); + const timestamp = "2026-01-01T00:00:00.000Z"; + for (let offset = 0; offset < ids.length; offset += 100) { + const batch = ids.slice(offset, offset + 100); + await getDb() + .insert(schema.documents) + .values( + batch.map((id, index) => ({ + id, + spaceId: SPACE_ID, + parentId: null, + ownerEmail: OWNER, + title: id, + content: "", + visibility: "private" as const, + position: offset + index, + createdAt: timestamp, + updatedAt: timestamp, + })), + ); + await getDb() + .insert(schema.contentDatabaseItems) + .values( + batch.map((id, index) => ({ + id: `membership-${id}`, + ownerEmail: OWNER, + databaseId: DATABASE_ID, + documentId: id, + position: offset + index, + createdAt: timestamp, + updatedAt: timestamp, + })), + ); + } + + const first = await navigate({ parentId: null }, 20); + expect(first.items.map((item) => item.documentId)).toEqual( + ids.slice(0, 20), + ); + expect(first.pagination.hasMore).toBe(true); + + const second = await navigate( + { parentId: null, cursor: first.pagination.nextCursor! }, + 20, + ); + expect(second.items.map((item) => item.documentId)).toEqual( + ids.slice(20, 40), + ); + expect(second.pagination.hasMore).toBe(true); + + const repeatedSecond = await navigate( + { parentId: null, cursor: first.pagination.nextCursor! }, + 20, + ); + expect(repeatedSecond).toEqual(second); + + await getDb() + .delete(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.documentId, ids)); + await getDb() + .delete(schema.documents) + .where(inArray(schema.documents.id, ids)); + }, + 60_000, + ); + + it("pages roots and immediate children without cross-parent or inaccessible leakage", async () => { + for (let index = 0; index < 22; index += 1) { + await addFile({ + id: `root-${String(index).padStart(2, "0")}`, + position: index, + }); + } + for (let index = 0; index < 22; index += 1) { + await addFile({ + id: `child-a-${String(index).padStart(2, "0")}`, + parentId: "root-00", + position: index, + }); + } + await addFile({ id: "child-b", parentId: "root-01" }); + await addFile({ id: "private-foreign", ownerEmail: OTHER, position: -1 }); + + const first = await navigate({ parentId: null }, 20); + expect(first.items).toHaveLength(20); + expect(first.items.every((item) => item.parentId === null)).toBe(true); + expect( + first.items.some((item) => item.documentId === "private-foreign"), + ).toBe(false); + expect(first.pagination.hasMore).toBe(true); + expect(first.pagination.nextCursor).toEqual(expect.any(String)); + expect(first.items[0]).toEqual({ + membershipId: "membership-root-00", + membershipPosition: 0, + documentId: "root-00", + parentId: null, + title: "root-00", + icon: "file", + spaceId: SPACE_ID, + sourceKind: null, + isFavorite: false, + canEdit: true, + canManage: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + type: "page", + hasChildren: true, + }); + expect(first.items[0]).not.toHaveProperty("content"); + + const second = await navigate({ + parentId: null, + cursor: first.pagination.nextCursor!, + }); + expect(second.items.map((item) => item.documentId)).toEqual([ + "root-20", + "root-21", + ]); + + const children = await navigate({ parentId: "root-00" }); + expect(children.items).toHaveLength(20); + expect(children.items.every((item) => item.parentId === "root-00")).toBe( + true, + ); + expect(children.items.some((item) => item.documentId === "child-b")).toBe( + false, + ); + const remainingChildren = await navigate({ + parentId: "root-00", + cursor: children.pagination.nextCursor!, + }); + expect(remainingChildren.items).toHaveLength(2); + }); + + it("rejects parents outside the Files database or the caller's access", async () => { + const timestamp = "2026-01-01T00:00:00.000Z"; + await getDb().insert(schema.documents).values({ + id: "outside-files-parent", + spaceId: SPACE_ID, + ownerEmail: OWNER, + title: "Outside Files", + content: "", + visibility: "private", + createdAt: timestamp, + updatedAt: timestamp, + }); + await addFile({ + id: "inaccessible-files-parent", + ownerEmail: OTHER, + }); + + await expect( + navigate({ parentId: "outside-files-parent" }), + ).rejects.toThrow("The Files navigation parent is unavailable."); + await expect( + navigate({ parentId: "inaccessible-files-parent" }), + ).rejects.toThrow("The Files navigation parent is unavailable."); + }); + + it("uses stable tie IDs for every supported server sort", async () => { + await addFile({ id: "ties-parent", position: 100 }); + for (const id of ["tie-c", "tie-a", "tie-b"]) { + await addFile({ + id, + parentId: "ties-parent", + position: 0, + title: "Same", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-02T00:00:00.000Z", + }); + } + + for (const sort of ["custom", "name", "created", "last_edited"] as const) { + const first = await navigate({ parentId: "ties-parent", sort }, 2); + const second = await navigate({ + parentId: "ties-parent", + sort, + cursor: first.pagination.nextCursor!, + }); + expect( + [...first.items, ...second.items].map((item) => item.documentId), + ).toEqual(["tie-a", "tie-b", "tie-c"]); + } + }); + + it("uses sidebar sort direction instead of an unrelated view direction", async () => { + await addFile({ + id: "sidebar-sort-parent", + position: 101, + }); + await addFile({ + id: "sidebar-sort-alpha", + parentId: "sidebar-sort-parent", + title: "Alpha", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-03T00:00:00.000Z", + }); + await addFile({ + id: "sidebar-sort-beta", + parentId: "sidebar-sort-parent", + title: "Beta", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await addFile({ + id: "sidebar-sort-gamma", + parentId: "sidebar-sort-parent", + title: "Gamma", + createdAt: "2026-03-03T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + }); + const { personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js"); + const saveSidebarSort = (mode: "name" | "created" | "last_edited") => + putUserSetting(OWNER, personalDatabaseViewSettingKey(DATABASE_ID), { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: "files", + views: [ + { + id: "files", + sorts: [{ key: "name", label: "Name", direction: "desc" }], + filters: [], + filterMode: "and", + sidebarOrder: { mode, itemIds: [] }, + }, + ], + }); + + await saveSidebarSort("name"); + expect( + (await navigate({ parentId: "sidebar-sort-parent" })).items.map( + (item) => item.documentId, + ), + ).toEqual([ + "sidebar-sort-alpha", + "sidebar-sort-beta", + "sidebar-sort-gamma", + ]); + await saveSidebarSort("created"); + expect( + (await navigate({ parentId: "sidebar-sort-parent" })).items.map( + (item) => item.documentId, + ), + ).toEqual([ + "sidebar-sort-gamma", + "sidebar-sort-beta", + "sidebar-sort-alpha", + ]); + await saveSidebarSort("last_edited"); + expect( + (await navigate({ parentId: "sidebar-sort-parent" })).items.map( + (item) => item.documentId, + ), + ).toEqual([ + "sidebar-sort-alpha", + "sidebar-sort-gamma", + "sidebar-sort-beta", + ]); + }); + + it("returns exactly 25 mixed ranked and unlisted siblings once across sequential pages", async () => { + await addFile({ id: "exact-page-parent", position: 200 }); + const documentIds = Array.from( + { length: 25 }, + (_, index) => `exact-page-${String(index + 1).padStart(2, "0")}`, + ); + for (const [position, id] of documentIds.entries()) { + await addFile({ + id, + parentId: "exact-page-parent", + position: (position * 7) % documentIds.length, + }); + } + const rankedDocumentIds = [ + "exact-page-07", + "exact-page-02", + "exact-page-19", + ]; + const { personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js"); + await putUserSetting(OWNER, personalDatabaseViewSettingKey(DATABASE_ID), { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: "files", + views: [ + { + id: "files", + sorts: [], + filters: [], + filterMode: "and", + sidebarOrder: { + mode: "custom", + itemIds: rankedDocumentIds.map((id) => `membership-${id}`), + }, + }, + ], + }); + + const first = await navigate( + { parentId: "exact-page-parent", sort: "custom" }, + 20, + ); + const second = await navigate({ + parentId: "exact-page-parent", + sort: "custom", + cursor: first.pagination.nextCursor!, + }); + + const expectedDocumentIds = [ + ...rankedDocumentIds, + ...documentIds + .filter((id) => !rankedDocumentIds.includes(id)) + .sort((left, right) => { + const leftIndex = documentIds.indexOf(left); + const rightIndex = documentIds.indexOf(right); + return ((leftIndex * 7) % 25) - ((rightIndex * 7) % 25); + }), + ]; + expect(first.items.map((item) => item.documentId)).toEqual( + expectedDocumentIds.slice(0, 20), + ); + expect(second.items.map((item) => item.documentId)).toEqual( + expectedDocumentIds.slice(20), + ); + expect( + new Set([...first.items, ...second.items].map((item) => item.documentId)) + .size, + ).toBe(25); + expect(second.pagination).toMatchObject({ + hasMore: false, + nextCursor: null, + }); + }); + + it("applies personal custom order and invalidates cursors when it changes", async () => { + const { personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js"); + const saveOrder = (itemIds: string[]) => + putUserSetting(OWNER, personalDatabaseViewSettingKey(DATABASE_ID), { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: "files", + views: [ + { + id: "files", + sorts: [], + filters: [], + filterMode: "and", + sidebarOrder: { mode: "custom", itemIds }, + }, + ], + }); + await saveOrder([ + "membership-root-05", + "membership-root-03", + "membership-root-04", + ]); + + const first = await navigate({ parentId: null }, 2); + expect(first.items.map((item) => item.documentId)).toEqual([ + "root-05", + "root-03", + ]); + await saveOrder([ + "membership-root-03", + "membership-root-05", + "membership-root-04", + ]); + await expect( + navigate({ parentId: null, cursor: first.pagination.nextCursor! }, 2), + ).rejects.toMatchObject({ errorCode: "invalid_navigation_cursor" }); + }); + + it("rejects malformed and wrong-scope cursors instead of falling back", async () => { + await expect( + navigate({ parentId: null, cursor: "not-a-cursor" }), + ).rejects.toMatchObject({ errorCode: "invalid_navigation_cursor" }); + + const first = await navigate({ parentId: null }, 1); + await expect( + navigate({ + parentId: "root-00", + cursor: first.pagination.nextCursor!, + }), + ).rejects.toMatchObject({ errorCode: "invalid_navigation_cursor" }); + }); + + it("invalidates a page cursor for accessible sibling edits but ignores inaccessible rows", async () => { + await addFile({ id: "revision-parent", position: 400 }); + await addFile({ + id: "revision-a", + parentId: "revision-parent", + position: 0, + }); + await addFile({ + id: "revision-b", + parentId: "revision-parent", + position: 1, + }); + const first = await navigate( + { parentId: "revision-parent", sort: "name" }, + 1, + ); + await getDb() + .update(schema.documents) + .set({ title: "renamed", updatedAt: "2026-03-01T00:00:00.000Z" }) + .where(eq(schema.documents.id, "revision-b")); + await expect( + navigate( + { + parentId: "revision-parent", + sort: "name", + cursor: first.pagination.nextCursor!, + }, + 1, + ), + ).rejects.toMatchObject({ errorCode: "invalid_navigation_cursor" }); + + const beforeReparent = await navigate( + { parentId: "revision-parent", sort: "name" }, + 1, + ); + await getDb() + .update(schema.documents) + .set({ parentId: null }) + .where(eq(schema.documents.id, "revision-b")); + await expect( + navigate( + { + parentId: "revision-parent", + sort: "name", + cursor: beforeReparent.pagination.nextCursor!, + }, + 1, + ), + ).rejects.toMatchObject({ errorCode: "invalid_navigation_cursor" }); + await getDb() + .update(schema.documents) + .set({ parentId: "revision-parent" }) + .where(eq(schema.documents.id, "revision-b")); + + await addFile({ + id: "revision-shared", + parentId: "revision-parent", + ownerEmail: OTHER, + position: 3, + }); + await getDb().insert(schema.documentShares).values({ + id: "revision-shared-share", + resourceId: "revision-shared", + principalType: "user", + principalId: OWNER, + role: "viewer", + createdBy: OTHER, + createdAt: new Date().toISOString(), + }); + const beforeRevoke = await navigate( + { parentId: "revision-parent", sort: "name" }, + 1, + ); + await getDb() + .delete(schema.documentShares) + .where(eq(schema.documentShares.id, "revision-shared-share")); + await expect( + navigate( + { + parentId: "revision-parent", + sort: "name", + cursor: beforeRevoke.pagination.nextCursor!, + }, + 1, + ), + ).rejects.toMatchObject({ errorCode: "invalid_navigation_cursor" }); + + const beforeDelete = await navigate( + { parentId: "revision-parent", sort: "name" }, + 1, + ); + await getDb() + .update(schema.documents) + .set({ trashedAt: "2026-03-03T00:00:00.000Z" }) + .where(eq(schema.documents.id, "revision-b")); + await expect( + navigate( + { + parentId: "revision-parent", + sort: "name", + cursor: beforeDelete.pagination.nextCursor!, + }, + 1, + ), + ).rejects.toMatchObject({ errorCode: "invalid_navigation_cursor" }); + await getDb() + .update(schema.documents) + .set({ trashedAt: null }) + .where(eq(schema.documents.id, "revision-b")); + + await addFile({ + id: "revision-inaccessible", + parentId: "revision-parent", + ownerEmail: OTHER, + position: 2, + }); + const stable = await navigate( + { parentId: "revision-parent", sort: "name" }, + 1, + ); + await getDb() + .update(schema.documents) + .set({ title: "still hidden", updatedAt: "2026-03-02T00:00:00.000Z" }) + .where(eq(schema.documents.id, "revision-inaccessible")); + await expect( + navigate( + { + parentId: "revision-parent", + sort: "name", + cursor: stable.pagination.nextCursor!, + }, + 1, + ), + ).resolves.toMatchObject({ items: expect.any(Array) }); + }); + + it("applies effective view filters before paging and invalidates changed-filter cursors", async () => { + await addFile({ id: "shared-view-parent", position: 500 }); + await addFile({ + id: "shared-view-a", + parentId: "shared-view-parent", + title: "A", + }); + await addFile({ + id: "shared-view-z", + parentId: "shared-view-parent", + title: "Z", + }); + await addFile({ + id: "shared-view-also-a", + parentId: "shared-view-parent", + title: "Also A", + }); + const saveView = async ( + filters: unknown[] = [], + direction = "desc", + filterMode = "and", + ) => { + await getDb() + .update(schema.contentDatabases) + .set({ + viewConfigJson: JSON.stringify({ + activeViewId: "default", + views: [ + { + id: "default", + name: "Table", + type: "table", + sorts: [{ key: "name", label: "Name", direction }], + filters, + filterMode, + columnWidths: {}, + }, + ], + }), + }) + .where(eq(schema.contentDatabases.id, DATABASE_ID)); + }; + await saveView(); + const first = await navigate( + { parentId: "shared-view-parent", viewId: "default" }, + 1, + ); + expect(first.items[0]?.documentId).toBe("shared-view-z"); + await saveView([], "asc"); + await expect( + navigate( + { + parentId: "shared-view-parent", + viewId: "default", + cursor: first.pagination.nextCursor!, + }, + 1, + ), + ).rejects.toMatchObject({ errorCode: "invalid_navigation_cursor" }); + await saveView([ + { key: "name", label: "Name", operator: "contains", value: "A" }, + ]); + const filtered = await navigate( + { parentId: "shared-view-parent", viewId: "default" }, + 1, + ); + expect(filtered.items.map((item) => item.documentId)).toEqual([ + "shared-view-also-a", + ]); + expect(filtered.pagination.hasMore).toBe(true); + await saveView([ + { key: "name", label: "Name", operator: "equals", value: "Z" }, + ]); + await expect( + navigate( + { + parentId: "shared-view-parent", + viewId: "default", + cursor: filtered.pagination.nextCursor!, + }, + 1, + ), + ).rejects.toMatchObject({ errorCode: "invalid_navigation_cursor" }); + + await saveView( + [ + { key: "name", label: "Name", operator: "equals", value: "A" }, + { key: "name", label: "Name", operator: "equals", value: "Z" }, + ], + "asc", + "and", + ); + expect( + (await navigate({ parentId: "shared-view-parent", viewId: "default" })) + .items, + ).toEqual([]); + await saveView( + [ + { key: "name", label: "Name", operator: "equals", value: "A" }, + { key: "name", label: "Name", operator: "equals", value: "Z" }, + ], + "asc", + "or", + ); + expect( + ( + await navigate({ parentId: "shared-view-parent", viewId: "default" }) + ).items.map((item) => item.title), + ).toEqual(["A", "Z"]); + await getDb() + .update(schema.contentDatabases) + .set({ viewConfigJson: "{}" }) + .where(eq(schema.contentDatabases.id, DATABASE_ID)); + }); + + it("uses personal filter mode, filters hasChildren, and scopes access before limit", async () => { + await addFile({ + id: "filtered-parent", + title: "Keep parent", + position: 700, + }); + await addFile({ + id: "filtered-child", + parentId: "filtered-parent", + title: "Keep child", + }); + await addFile({ + id: "excluded-child", + parentId: "filtered-parent", + title: "Drop child", + }); + await addFile({ + id: "empty-filtered-parent", + title: "Keep empty", + position: 701, + }); + await addFile({ + id: "only-excluded-child", + parentId: "empty-filtered-parent", + title: "Drop only", + }); + await addFile({ + id: "matching-inaccessible", + title: "Keep hidden", + ownerEmail: OTHER, + position: -10, + }); + const { personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js"); + await putUserSetting(OWNER, personalDatabaseViewSettingKey(DATABASE_ID), { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: "files", + views: [ + { + id: "files", + sorts: [{ key: "name", label: "Name", direction: "asc" }], + filters: [ + { key: "name", label: "Name", operator: "contains", value: "Keep" }, + { key: "name", label: "Name", operator: "equals", value: "never" }, + ], + filterMode: "or", + sidebarOrder: { mode: "name", itemIds: [] }, + }, + ], + }); + + const roots = await navigate({ parentId: null }, 2); + expect(roots.items.map((item) => item.documentId)).toEqual([ + "empty-filtered-parent", + "filtered-parent", + ]); + expect(roots.pagination.hasMore).toBe(false); + expect(roots.items.map((item) => item.hasChildren)).toEqual([false, true]); + const children = await navigate({ parentId: "filtered-parent" }); + expect(children.items.map((item) => item.documentId)).toEqual([ + "filtered-child", + ]); + }); + + it("uses the accessible authoritative space Files membership, not an arbitrary membership", async () => { + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: "rogue-files-document", + spaceId: "rogue-space", + ownerEmail: OTHER, + title: "Rogue Files", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentSpaces).values({ + id: "rogue-space", + name: "Rogue", + kind: "personal", + ownerEmail: OTHER, + filesDatabaseId: "rogue-files", + createdBy: OTHER, + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: "rogue-files", + spaceId: "rogue-space", + ownerEmail: OTHER, + documentId: "rogue-files-document", + title: "Rogue Files", + systemRole: "files", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabaseItems).values({ + id: "rogue-root-membership", + ownerEmail: OTHER, + databaseId: "rogue-files", + documentId: "root-00", + position: 0, + createdAt: now, + updatedAt: now, + }); + + const context = await runWithRequestContext({ userEmail: OWNER }, () => + navigationContextAction.run({ id: "root-00" }), + ); + expect(context.workspaceFilesDatabaseId).toBe(DATABASE_ID); + expect(context.path.at(-1)).toMatchObject({ databaseId: DATABASE_ID }); + }); + + it("returns the authoritative Files database for the Files document itself", async () => { + const context = await runWithRequestContext({ userEmail: OWNER }, () => + navigationContextAction.run({ id: "navigation-files-document" }), + ); + + expect(context.workspaceFilesDatabaseId).toBe(DATABASE_ID); + expect(context.path).toHaveLength(1); + expect(context.path[0]).toMatchObject({ + id: "navigation-files-document", + databaseId: DATABASE_ID, + databaseDocumentId: "navigation-files-document", + }); + }); + + it("resolves an authorized organization Files root and child independently of the active organization", async () => { + await addOrganizationFile({ id: "organization-child" }); + + for (const orgId of [ORGANIZATION_A_ID, undefined]) { + const filesContext = await runWithRequestContext( + { userEmail: ORGANIZATION_MEMBER, orgId }, + () => + navigationContextAction.run({ id: ORGANIZATION_FILES_DOCUMENT_ID }), + ); + expect(filesContext.workspaceFilesDatabaseId).toBe( + ORGANIZATION_DATABASE_ID, + ); + expect(filesContext.path.map((entry) => entry.id)).toEqual([ + ORGANIZATION_FILES_DOCUMENT_ID, + ]); + + const childContext = await runWithRequestContext( + { userEmail: ORGANIZATION_MEMBER, orgId }, + () => navigationContextAction.run({ id: "organization-child" }), + ); + expect(childContext.workspaceFilesDatabaseId).toBe( + ORGANIZATION_DATABASE_ID, + ); + expect(childContext.path.map((entry) => entry.id)).toEqual([ + "organization-child", + ]); + } + }); + + it("denies Files navigation without selected-space membership", async () => { + await expect( + runWithRequestContext({ userEmail: ORGANIZATION_DENIED }, () => + navigationContextAction.run({ id: "organization-child" }), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("does not disclose a restricted ancestor from an authorized organization space", async () => { + await addOrganizationFile({ + id: "restricted-organization-parent", + visibility: "private", + }); + await addOrganizationFile({ + id: "organization-child-with-restricted-parent", + parentId: "restricted-organization-parent", + }); + + const context = await runWithRequestContext( + { userEmail: ORGANIZATION_MEMBER, orgId: ORGANIZATION_A_ID }, + () => + navigationContextAction.run({ + id: "organization-child-with-restricted-parent", + }), + ); + + expect(context.workspaceFilesDatabaseId).toBe(ORGANIZATION_DATABASE_ID); + expect(context.path.map((entry) => entry.id)).toEqual([ + "organization-child-with-restricted-parent", + ]); + }); + + it("associates a child without denormalized spaceId to its authoritative Files path", async () => { + await addFile({ id: "space-less-parent", position: 300 }); + await addFile({ + id: "space-less-child", + parentId: "space-less-parent", + position: 0, + spaceId: null, + }); + + const context = await runWithRequestContext({ userEmail: OWNER }, () => + navigationContextAction.run({ id: "space-less-child" }), + ); + + expect(context.workspaceFilesDatabaseId).toBe(DATABASE_ID); + expect(context.path.map((entry) => entry.id)).toEqual([ + "space-less-parent", + "space-less-child", + ]); + expect( + context.path.every((entry) => entry.databaseId === DATABASE_ID), + ).toBe(true); + }); + + it("returns each path entry's own permissions and metadata", async () => { + await addFile({ + id: "weak-parent", + ownerEmail: OTHER, + position: 600, + title: "Weak parent", + }); + await getDb() + .update(schema.documents) + .set({ visibility: "public", icon: "parent-icon" }) + .where(eq(schema.documents.id, "weak-parent")); + await getDb().insert(schema.documentShares).values({ + id: "weak-parent-viewer-share", + resourceId: "weak-parent", + principalType: "user", + principalId: OWNER, + role: "viewer", + createdBy: OTHER, + createdAt: new Date().toISOString(), + }); + await addFile({ id: "strong-child", parentId: "weak-parent", position: 0 }); + const context = await runWithRequestContext({ userEmail: OWNER }, () => + navigationContextAction.run({ id: "strong-child" }), + ); + expect(context.path[0]).toMatchObject({ + id: "weak-parent", + title: "Weak parent", + icon: "parent-icon", + accessRole: "viewer", + canEdit: false, + canManage: false, + }); + expect(context.path[1]).toMatchObject({ + id: "strong-child", + accessRole: "owner", + canEdit: true, + canManage: true, + }); + }); + + it("requires an explicit parent and rejects unsupported pagination combinations", () => { + expect(() => + action.schema.parse({ databaseId: DATABASE_ID, navigation: {} }), + ).toThrow(); + expect(() => + action.schema.parse({ + databaseId: DATABASE_ID, + navigation: { parentId: null }, + limit: 21, + }), + ).not.toThrow(); + return expect( + runWithRequestContext({ userEmail: OWNER }, () => + action.run( + { + databaseId: DATABASE_ID, + navigation: { parentId: null }, + limit: 21, + }, + { userEmail: OWNER } as any, + ), + ), + ).rejects.toMatchObject({ errorCode: "navigation_limit_exceeded" }); + }); +}); diff --git a/templates/content/actions/query-content-database-items.ts b/templates/content/actions/query-content-database-items.ts index dbe2c4657ba..219fc5ec2d9 100644 --- a/templates/content/actions/query-content-database-items.ts +++ b/templates/content/actions/query-content-database-items.ts @@ -1,10 +1,12 @@ -import { defineAction } from "@agent-native/core/action"; +import { defineAction, fail } from "@agent-native/core/action"; import { z } from "zod"; import type { ContentDatabaseItemsPageResponse, + ContentDatabaseNavigationPageResponse, ContentDatabaseUnavailableResponse, } from "../shared/api.js"; +import { getContentDatabaseNavigationPage } from "./_database-navigation.js"; import { CONTENT_DATABASE_MAX_READ_LIMIT, contentDatabaseTableQuerySchema, @@ -26,25 +28,76 @@ export default defineAction({ .optional(), offset: z.coerce.number().int().min(0).optional(), tableQuery: contentDatabaseTableQuerySchema, + navigation: z + .object({ + parentId: z + .string() + .nullable() + .describe( + "null for roots, or the exact immediate parent document ID", + ), + sort: z + .enum(["custom", "name", "created", "last_edited"]) + .default("custom") + .describe("Sidebar order; defaults to custom"), + viewId: z + .string() + .optional() + .describe("Personal View ID used for custom order"), + cursor: z + .string() + .min(1) + .optional() + .describe("Opaque cursor returned by the previous matching page"), + }) + .optional() + .describe( + "Bounded Files navigation query. parentId is required and must be null for roots or an exact immediate parent ID.", + ), }), http: { method: "GET" }, readOnly: true, agentTool: false, - run: async ({ - databaseId, - documentId, - limit, - offset, - tableQuery, - }): Promise< - ContentDatabaseItemsPageResponse | ContentDatabaseUnavailableResponse + run: async ( + { databaseId, documentId, limit, offset, tableQuery, navigation }, + ctx, + ): Promise< + | ContentDatabaseItemsPageResponse + | ContentDatabaseUnavailableResponse + | ContentDatabaseNavigationPageResponse > => { + if (navigation && (offset !== undefined || tableQuery !== undefined)) { + fail("Files navigation does not support offset or table constraints.", { + errorCode: "unsupported_navigation_query", + statusCode: 400, + }); + } + const userEmail = ctx?.userEmail; + if (navigation && limit !== undefined && limit > 20) { + fail("Files navigation supports at most 20 items per page.", { + errorCode: "navigation_limit_exceeded", + statusCode: 400, + }); + } const resolved = await resolveContentDatabaseRead({ databaseId, documentId, }); if (!resolved.available) return resolved; + if (navigation) { + if (!userEmail) throw new Error("Not authenticated."); + return getContentDatabaseNavigationPage({ + database: resolved.database, + userEmail, + parentId: navigation.parentId, + sort: navigation.sort, + viewId: navigation.viewId, + limit: limit ?? 20, + cursor: navigation.cursor, + }); + } + const page = await getContentDatabasePageResponse(resolved.database.id, { // This action is the bounded table replacement path; unlike the legacy // database response, an omitted limit must not turn it into a full read. diff --git a/templates/content/actions/record-content-visit.ts b/templates/content/actions/record-content-visit.ts new file mode 100644 index 00000000000..6ed564115ad --- /dev/null +++ b/templates/content/actions/record-content-visit.ts @@ -0,0 +1,40 @@ +import { defineAction, fail } from "@agent-native/core/action"; +import { mutateUserSetting } from "@agent-native/core/settings"; + +import { + contentRecentTargetSchema, + readContentRecentState, + recordContentRecentVisit, +} from "../shared/content-personal-navigation.js"; +import { + contentRecentSettingKey, + resolveContentRecentEntries, +} from "./_content-recent.js"; + +export default defineAction({ + description: + "Record a successfully opened foreground Page or exact database View in the current user's Recent navigation. Does not edit or pin the target.", + schema: contentRecentTargetSchema, + agentTool: false, + run: async (target, ctx) => { + if (!ctx?.userEmail) fail("Not authenticated.", { statusCode: 401 }); + const entry = { target, visitedAt: new Date().toISOString() }; + const resolved = await resolveContentRecentEntries(ctx.userEmail, [entry]); + if (resolved.length !== 1) + fail("This location is unavailable.", { + statusCode: 404, + errorCode: "location_unavailable", + }); + const canonicalEntry = { ...entry, target: resolved[0].target }; + await mutateUserSetting( + ctx.userEmail, + contentRecentSettingKey(), + (current) => + recordContentRecentVisit( + readContentRecentState(current), + canonicalEntry, + ), + ); + return { recorded: true }; + }, +}); diff --git a/templates/content/actions/resolve-content-landing.db.test.ts b/templates/content/actions/resolve-content-landing.db.test.ts index cf447993f52..409d926f514 100644 --- a/templates/content/actions/resolve-content-landing.db.test.ts +++ b/templates/content/actions/resolve-content-landing.db.test.ts @@ -4,13 +4,15 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeAppState } from "@agent-native/core/application-state"; +import { getDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { CONTENT_LAST_LOCATION_STATE_KEY, CONTENT_WELCOME_PAGE_STATE_KEY, + contentSpaceLastLocationStateKey, } from "../shared/content-landing.js"; const TEST_DB_PATH = join( @@ -34,6 +36,8 @@ type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; let schema: Schema; let provisionContentSpaces: typeof import("./_content-spaces.js").provisionContentSpaces; +let createContentSpaceAction: typeof import("./create-content-space.js").default; +let createDocumentAction: typeof import("./create-document.js").default; let resolveContentLandingAction: typeof import("./resolve-content-landing.js").default; beforeAll(async () => { @@ -42,10 +46,21 @@ beforeAll(async () => { getDb = dbModule.getDb; schema = dbModule.schema; ({ provisionContentSpaces } = await import("./_content-spaces.js")); + createContentSpaceAction = (await import("./create-content-space.js")) + .default; + createDocumentAction = (await import("./create-document.js")).default; resolveContentLandingAction = (await import("./resolve-content-landing.js")) .default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); + await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT NOT NULL, created_at BIGINT NOT NULL, + identity_authority TEXT, identity_id TEXT + )`); + await getDbExec().execute(`CREATE TABLE IF NOT EXISTS org_members ( + id TEXT PRIMARY KEY, org_id TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, joined_at BIGINT NOT NULL, + federation_removal_pending_at BIGINT + )`); }, 60_000); afterAll(() => { @@ -73,6 +88,187 @@ async function createPersonalDocument(userEmail: string, id: string) { } describe("resolve-content-landing", () => { + it("creates and reuses a real welcome page in a user workspace", async () => { + const userEmail = "workspace-welcome@example.com"; + const result = await runWithRequestContext({ userEmail }, async () => { + const workspace = await createContentSpaceAction.run({ + name: "Workspace welcome", + requestId: "workspace-welcome", + }); + const concurrent = await Promise.all([ + resolveContentLandingAction.run({ spaceId: workspace.spaceId }), + resolveContentLandingAction.run({ spaceId: workspace.spaceId }), + resolveContentLandingAction.run({ spaceId: workspace.spaceId }), + ]); + const second = await resolveContentLandingAction.run({ + spaceId: workspace.spaceId, + }); + return { workspace, concurrent, second }; + }); + + expect(result.concurrent.map(({ resolution }) => resolution)).toContain( + "welcome-created", + ); + expect( + new Set(result.concurrent.map(({ target }) => target?.documentId)).size, + ).toBe(1); + const [first] = result.concurrent; + expect(first.target).toEqual({ documentId: expect.any(String) }); + expect(result.second).toEqual({ + resolution: "welcome-reused", + target: first.target, + }); + const welcomeDocumentId = first.target?.documentId; + const [welcome] = await getDb() + .select({ + spaceId: schema.documents.spaceId, + visibility: schema.documents.visibility, + }) + .from(schema.documents) + .where(eq(schema.documents.id, welcomeDocumentId!)); + const [membership] = await getDb() + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + result.workspace.filesDatabaseId, + ), + eq(schema.contentDatabaseItems.documentId, welcomeDocumentId!), + ), + ); + expect(welcome).toMatchObject({ + spaceId: result.workspace.spaceId, + visibility: "private", + }); + expect(membership).toEqual({ documentId: welcomeDocumentId }); + }); + + it("reuses an organization workspace welcome for another member", async () => { + const ownerEmail = "workspace-welcome-owner@example.com"; + const memberEmail = "workspace-welcome-member@example.com"; + const orgId = "workspace-welcome-org"; + await getDbExec().execute({ + sql: "INSERT INTO organizations (id, name, created_by, created_at) VALUES ($1, $2, $3, $4)", + args: [orgId, "Workspace Welcome Org", ownerEmail, Date.now()], + }); + for (const [id, email, role] of [ + ["workspace-welcome-owner", ownerEmail, "owner"], + ["workspace-welcome-member", memberEmail, "member"], + ]) { + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES ($1, $2, $3, $4, $5)", + args: [id, orgId, email, role, Date.now()], + }); + } + + const provisioned = await runWithRequestContext( + { userEmail: ownerEmail }, + () => provisionContentSpaces(getDb(), ownerEmail), + ); + const spaceId = provisioned.spaceIds.find( + (candidate) => candidate !== provisioned.personalSpaceId, + )!; + const created = await runWithRequestContext({ userEmail: ownerEmail }, () => + resolveContentLandingAction.run({ spaceId }), + ); + const reused = await runWithRequestContext({ userEmail: memberEmail }, () => + resolveContentLandingAction.run({ spaceId }), + ); + + expect(created.resolution).toBe("welcome-created"); + expect(reused).toEqual({ + resolution: "welcome-reused", + target: created.target, + }); + const [welcome] = await getDb() + .select({ + orgId: schema.documents.orgId, + visibility: schema.documents.visibility, + }) + .from(schema.documents) + .where(eq(schema.documents.id, created.target!.documentId)); + expect(welcome).toEqual({ orgId, visibility: "org" }); + }); + + it("restores each workspace's exact saved page independently", async () => { + const userEmail = "workspace-restore@example.com"; + const result = await runWithRequestContext({ userEmail }, async () => { + const firstWorkspace = await createContentSpaceAction.run({ + name: "First workspace", + requestId: "workspace-restore-first", + }); + const secondWorkspace = await createContentSpaceAction.run({ + name: "Second workspace", + requestId: "workspace-restore-second", + }); + const firstPage = await createDocumentAction.run({ + title: "First saved page", + spaceId: firstWorkspace.spaceId, + }); + const secondPage = await createDocumentAction.run({ + title: "Second saved page", + spaceId: secondWorkspace.spaceId, + }); + await writeAppState( + contentSpaceLastLocationStateKey(firstWorkspace.spaceId), + { documentId: firstPage.id }, + ); + await writeAppState( + contentSpaceLastLocationStateKey(secondWorkspace.spaceId), + { documentId: secondPage.id }, + ); + const landings = await Promise.all([ + resolveContentLandingAction.run({ spaceId: firstWorkspace.spaceId }), + resolveContentLandingAction.run({ spaceId: secondWorkspace.spaceId }), + ]); + return { firstPage, secondPage, landings }; + }); + + expect(result.landings).toEqual([ + { + target: { documentId: result.firstPage.id }, + resolution: "restored", + }, + { + target: { documentId: result.secondPage.id }, + resolution: "restored", + }, + ]); + }); + + it("falls back when a saved database identity is no longer available", async () => { + const userEmail = "workspace-missing-database@example.com"; + const result = await runWithRequestContext({ userEmail }, async () => { + const workspace = await createContentSpaceAction.run({ + name: "Missing database workspace", + requestId: "workspace-missing-database", + }); + const page = await createDocumentAction.run({ + title: "Page with stale database destination", + spaceId: workspace.spaceId, + }); + await writeAppState(contentSpaceLastLocationStateKey(workspace.spaceId), { + documentId: page.id, + databaseId: "missing-database", + }); + return { + page, + landing: await resolveContentLandingAction.run({ + spaceId: workspace.spaceId, + }), + }; + }); + + expect(result.landing).toMatchObject({ + resolution: "fallback", + fallbackReason: "saved-document-unavailable", + target: { documentId: expect.any(String) }, + }); + expect(result.landing.target?.documentId).not.toBe(result.page.id); + }); + it("restores only a currently authorized saved document", async () => { const userEmail = "landing-restored@example.com"; const documentId = "landing-restored-document"; @@ -284,9 +480,8 @@ describe("resolve-content-landing", () => { "welcome-created", ); - const [welcome] = await getDb() + const welcomes = await getDb() .select({ - count: sql`count(*)`, visibility: schema.documents.visibility, parentId: schema.documents.parentId, }) @@ -297,7 +492,10 @@ describe("resolve-content-landing", () => { eq(schema.documents.title, WELCOME_TITLE), ), ); - expect(Number(welcome.count)).toBe(1); - expect(welcome).toMatchObject({ visibility: "private", parentId: null }); + expect(welcomes).toHaveLength(1); + expect(welcomes[0]).toMatchObject({ + visibility: "private", + parentId: null, + }); }); }); diff --git a/templates/content/actions/resolve-content-landing.ts b/templates/content/actions/resolve-content-landing.ts index 6e75e3edf51..7d4ba751ad0 100644 --- a/templates/content/actions/resolve-content-landing.ts +++ b/templates/content/actions/resolve-content-landing.ts @@ -8,19 +8,28 @@ import { } from "@agent-native/core/application-state"; import { runWithRequestContext } from "@agent-native/core/server"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; +import { getDb, schema } from "../server/db/index.js"; import { CONTENT_LAST_LOCATION_STATE_KEY, CONTENT_WELCOME_PAGE_STATE_KEY, + contentSpaceLastLocationStateKey, + contentSpaceWelcomePageStateKey, type ContentLandingResolution, type ContentLastLocationState, + type ContentSpaceLandingResult, type ContentWelcomePageState, } from "../shared/content-landing.js"; import { resolveContentDocumentAccess } from "./_content-document-access.js"; -import { normalizeContentSpaceEmail } from "./_content-space-access.js"; +import { + normalizeContentSpaceEmail, + resolveContentSpaceAccess, +} from "./_content-space-access.js"; import { personalContentSpaceId } from "./_content-spaces.js"; import { isSoftDeletedDatabaseDocument } from "./_database-utils.js"; +import { parseDatabaseViewConfig } from "./_property-utils.js"; import createDocumentAction from "./create-document.js"; const WELCOME_TITLE = "Welcome to Agent-Native Content"; @@ -37,6 +46,12 @@ function legacyWelcomeDocumentId(userEmail: string, generation: number) { return `content_welcome_${digest.slice(0, 32)}`; } +function spaceWelcomeDocumentId(spaceId: string, generation: number) { + const input = generation === 0 ? spaceId : `${spaceId}:${generation}`; + const digest = createHash("sha256").update(input).digest("hex"); + return `content_welcome_${digest.slice(0, 32)}`; +} + function randomWelcomeDocumentId() { return `content_welcome_${randomUUID().replace(/-/g, "")}`; } @@ -82,16 +97,14 @@ function isUniqueConstraintError(error: unknown): boolean { return false; } -async function resolveWelcomeState(): Promise<{ +async function resolveWelcomeState(stateKey: string): Promise<{ generation: number; documentId: string | null; expectedValue: Record; }> { while (true) { - const entries = await listAppState(CONTENT_WELCOME_PAGE_STATE_KEY); - const entry = entries.find( - ({ key }) => key === CONTENT_WELCOME_PAGE_STATE_KEY, - ); + const entries = await listAppState(stateKey); + const entry = entries.find(({ key }) => key === stateKey); if (entry) { if (!entry.value || typeof entry.value !== "object") { throw new Error("Content welcome page state must be an object"); @@ -107,9 +120,7 @@ async function resolveWelcomeState(): Promise<{ } const initial = { generation: 0 }; - if ( - await compareAndSetAppState(CONTENT_WELCOME_PAGE_STATE_KEY, null, initial) - ) { + if (await compareAndSetAppState(stateKey, null, initial)) { return { generation: 0, documentId: null, expectedValue: initial }; } } @@ -121,6 +132,22 @@ function savedDocumentId(state: Record | null): string | null { : null; } +function savedTarget( + state: Record | null, +): ContentLastLocationState | null { + const documentId = savedDocumentId(state); + if (!documentId) return null; + return { + documentId, + ...(typeof state?.databaseId === "string" && state.databaseId + ? { databaseId: state.databaseId } + : {}), + ...(typeof state?.viewId === "string" && state.viewId + ? { viewId: state.viewId } + : {}), + }; +} + async function resolveUsableDocument(documentId: string) { const access = await resolveContentDocumentAccess(documentId); if (!access?.resource || access.resource.trashedAt) return null; @@ -128,7 +155,72 @@ async function resolveUsableDocument(documentId: string) { return access.resource; } -async function resolveWelcomeDocument(userEmail: string, documentId: string) { +async function resolveUsableSpaceTarget( + spaceId: string, + target: ContentLastLocationState, +): Promise { + const db = getDb(); + const spaceAccess = await resolveContentSpaceAccess(spaceId, "viewer", { + db, + }); + const documentAccess = await resolveContentDocumentAccess(target.documentId); + const document = documentAccess?.resource; + if ( + !document || + document.trashedAt || + document.spaceId !== spaceId || + (await isSoftDeletedDatabaseDocument(target.documentId)) + ) { + return null; + } + const [membership] = await db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + spaceAccess.space.filesDatabaseId, + ), + eq(schema.contentDatabaseItems.documentId, target.documentId), + ), + ) + .limit(1); + if (!membership) return null; + if (!target.databaseId) return { documentId: target.documentId }; + const [database] = await db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + viewConfigJson: schema.contentDatabases.viewConfigJson, + }) + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, target.databaseId), + eq(schema.contentDatabases.documentId, target.documentId), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .limit(1); + if (!database) return null; + const config = parseDatabaseViewConfig(database.viewConfigJson); + const viewId = target.viewId + ? config.views.some((view) => view.id === target.viewId) + ? target.viewId + : ( + config.views.find((view) => view.id === config.activeViewId) ?? + config.views[0] + )?.id + : undefined; + return { + documentId: database.documentId, + databaseId: database.id, + ...(viewId ? { viewId } : {}), + }; +} + +async function resolveWelcomeDocument(spaceId: string, documentId: string) { const access = await resolveContentDocumentAccess(documentId); const document = access?.resource; if (!document) { @@ -140,39 +232,62 @@ async function resolveWelcomeDocument(userEmail: string, documentId: string) { return { status: "unavailable" as const }; } - const normalizedEmail = normalizeContentSpaceEmail(userEmail); - if ( - normalizeContentSpaceEmail(document.ownerEmail) !== normalizedEmail || - document.spaceId !== personalContentSpaceId(normalizedEmail) || - document.parentId !== null || - document.visibility !== "private" - ) { + if (document.spaceId !== spaceId || document.parentId !== null) { return { status: "unavailable" as const }; } + const db = getDb(); + const [membership] = await db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.contentDatabases, + eq(schema.contentDatabases.id, schema.contentDatabaseItems.databaseId), + ) + .where( + and( + eq(schema.contentDatabases.spaceId, spaceId), + eq(schema.contentDatabases.systemRole, "files"), + eq(schema.contentDatabaseItems.documentId, documentId), + ), + ) + .limit(1); + if (!membership) return { status: "unavailable" as const }; return { status: "usable" as const, documentId }; } -async function resolveWelcome(userEmail: string): Promise<{ - documentId: string; - resolution: Extract< - ContentLandingResolution, - "welcome-created" | "welcome-reused" - >; +async function resolveWelcome( + userEmail: string, + requestedSpaceId?: string, +): Promise<{ + documentId: string | null; + resolution: + | Extract + | "welcome-unavailable"; + fallbackReason?: "welcome-create-forbidden"; }> { const normalizedEmail = normalizeContentSpaceEmail(userEmail); + const spaceId = requestedSpaceId ?? personalContentSpaceId(normalizedEmail); + if (requestedSpaceId) { + await resolveContentSpaceAccess(spaceId, "viewer"); + } + const isPersonal = spaceId === personalContentSpaceId(normalizedEmail); + const stateKey = isPersonal + ? CONTENT_WELCOME_PAGE_STATE_KEY + : contentSpaceWelcomePageStateKey(spaceId); while (true) { - const state = await resolveWelcomeState(); + const state = await resolveWelcomeState(stateKey); const documentId = state.documentId ?? - legacyWelcomeDocumentId(normalizedEmail, state.generation); - const existing = await resolveWelcomeDocument(normalizedEmail, documentId); + (isPersonal + ? legacyWelcomeDocumentId(normalizedEmail, state.generation) + : spaceWelcomeDocumentId(spaceId, state.generation)); + const existing = await resolveWelcomeDocument(spaceId, documentId); if (existing.status === "usable") { if (!state.documentId) { - await compareAndSetAppState( - CONTENT_WELCOME_PAGE_STATE_KEY, - state.expectedValue, - { ...state.expectedValue, documentId }, - ); + await compareAndSetAppState(stateKey, state.expectedValue, { + ...state.expectedValue, + documentId, + }); } return { documentId: existing.documentId, @@ -181,15 +296,13 @@ async function resolveWelcome(userEmail: string): Promise<{ } if (!state.documentId || existing.status === "unavailable") { - await compareAndSetAppState( - CONTENT_WELCOME_PAGE_STATE_KEY, - state.expectedValue, - { - ...state.expectedValue, - generation: state.generation + 1, - documentId: randomWelcomeDocumentId(), - }, - ); + await compareAndSetAppState(stateKey, state.expectedValue, { + ...state.expectedValue, + generation: state.generation + 1, + documentId: isPersonal + ? randomWelcomeDocumentId() + : spaceWelcomeDocumentId(spaceId, state.generation + 1), + }); continue; } @@ -197,13 +310,14 @@ async function resolveWelcome(userEmail: string): Promise<{ await runWithRequestContext({ userEmail: normalizedEmail }, () => createDocumentAction.run({ id: documentId, + spaceId, title: WELCOME_TITLE, content: WELCOME_CONTENT, }), ); return { documentId, resolution: "welcome-created" }; } catch (error) { - const raced = await resolveWelcomeDocument(normalizedEmail, documentId); + const raced = await resolveWelcomeDocument(spaceId, documentId); if (raced.status === "usable") { return { documentId: raced.documentId, @@ -212,17 +326,25 @@ async function resolveWelcome(userEmail: string): Promise<{ } if (raced.status === "unavailable") continue; if (isUniqueConstraintError(error)) { - await compareAndSetAppState( - CONTENT_WELCOME_PAGE_STATE_KEY, - state.expectedValue, - { - ...state.expectedValue, - generation: state.generation + 1, - documentId: randomWelcomeDocumentId(), - }, - ); + await compareAndSetAppState(stateKey, state.expectedValue, { + ...state.expectedValue, + generation: state.generation + 1, + documentId: isPersonal + ? randomWelcomeDocumentId() + : spaceWelcomeDocumentId(spaceId, state.generation + 1), + }); continue; } + if ( + error instanceof Error && + /Contributor access is required/.test(error.message) + ) { + return { + documentId: null, + resolution: "welcome-unavailable", + fallbackReason: "welcome-create-forbidden", + }; + } throw error; } } @@ -231,11 +353,43 @@ async function resolveWelcome(userEmail: string): Promise<{ export default defineAction({ description: "Resolve the signed-in user's safe Content landing page, restoring an authorized last page when possible.", - schema: z.object({}), - run: async () => { + schema: z.object({ spaceId: z.string().min(1).max(256).optional() }), + run: async (args) => { const userEmail = getRequestUserEmail(); if (!userEmail) throw new Error("no authenticated user"); + const spaceId = args.spaceId; + if (spaceId) { + const lastLocation = await readAppState( + contentSpaceLastLocationStateKey(spaceId), + ); + const target = savedTarget(lastLocation); + const restored = target + ? await resolveUsableSpaceTarget(spaceId, target) + : null; + if (restored) { + return { + target: restored, + resolution: "restored" as const, + } satisfies ContentSpaceLandingResult; + } + const welcome = await resolveWelcome(userEmail, spaceId); + if (!welcome.documentId || welcome.resolution === "welcome-unavailable") { + return { + target: null, + resolution: "welcome-unavailable" as const, + fallbackReason: "welcome-create-forbidden" as const, + } satisfies ContentSpaceLandingResult; + } + return { + target: { documentId: welcome.documentId }, + resolution: target ? ("fallback" as const) : welcome.resolution, + ...(target + ? { fallbackReason: "saved-document-unavailable" as const } + : {}), + } satisfies ContentSpaceLandingResult; + } + const lastLocation = await readAppState(CONTENT_LAST_LOCATION_STATE_KEY); const lastDocumentId = savedDocumentId(lastLocation); if (lastDocumentId && (await resolveUsableDocument(lastDocumentId))) { diff --git a/templates/content/actions/update-content-database-personal-view.test.ts b/templates/content/actions/update-content-database-personal-view.test.ts index 8b46e77136e..a2f619eda92 100644 --- a/templates/content/actions/update-content-database-personal-view.test.ts +++ b/templates/content/actions/update-content-database-personal-view.test.ts @@ -13,6 +13,59 @@ import action, { } from "./update-content-database-personal-view"; describe("update content database personal view", () => { + it("rejects oversized or malformed sidebar order ids on full override saves", () => { + const overrides = { + version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + views: [ + { + id: "table", + sorts: [], + filters: [], + filterMode: "and", + sidebarOrder: { + mode: "custom", + itemIds: Array.from( + { length: 5_001 }, + (_, index) => `item-${index}`, + ), + }, + }, + ], + }; + const oversized = action.schema.safeParse({ + databaseId: "database", + overrides, + }); + expect(oversized.success).toBe(false); + if (!oversized.success) { + expect(oversized.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "too_big", + path: ["overrides", "views", 0, "sidebarOrder", "itemIds"], + }), + ]), + ); + } + + overrides.views[0]!.sidebarOrder.itemIds = ["x".repeat(257)]; + const malformed = action.schema.safeParse({ + databaseId: "database", + overrides, + }); + expect(malformed.success).toBe(false); + if (!malformed.success) { + expect(malformed.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "too_big", + path: ["overrides", "views", 0, "sidebarOrder", "itemIds", 0], + }), + ]), + ); + } + }); + it("accepts grouped filter overrides for the current user", () => { const parsed = action.schema.parse({ databaseId: "database", diff --git a/templates/content/actions/update-content-database-personal-view.ts b/templates/content/actions/update-content-database-personal-view.ts index 532e0e7d1a4..9ad69bcbdca 100644 --- a/templates/content/actions/update-content-database-personal-view.ts +++ b/templates/content/actions/update-content-database-personal-view.ts @@ -1,16 +1,23 @@ -import { defineAction } from "@agent-native/core/action"; -import { deleteUserSetting, putUserSetting } from "@agent-native/core/settings"; +import { defineAction, fail } from "@agent-native/core/action"; import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { + applyContentPersonalNavigationPatch, + contentPersonalNavigationPatchSchema, +} from "../shared/content-personal-navigation-patch.js"; import { bulkChunkSizeForColumnCount, chunks } from "./_batch-utils.js"; import { assertContentDatabaseViewerAccess, + migratePersonalDatabaseViewOverrides, + filterSchema, + sortSchema, normalizePersonalDatabaseViewOverrides, personalDatabaseViewSettingKey, personalViewOverridesSchema, } from "./_content-database-personal-view.js"; +import { mutateContentUserSettingTransaction } from "./_user-setting-transaction.js"; export function personalSidebarOrderItemIds( overrides: z.infer, @@ -24,39 +31,241 @@ export function personalSidebarOrderItemIds( export default defineAction({ description: - "Update or clear the current user's personal saved filter, sort, and active view overrides for a content collection.", - schema: z.object({ - databaseId: z.string().describe("Collection ID"), - overrides: personalViewOverridesSchema.nullable(), - }), - run: async ({ databaseId, overrides }, ctx) => { + "Update or clear personal database View overrides. Use navigation to atomically select a View or reorder its sidebar references while preserving other personal settings.", + schema: z + .object({ + databaseId: z.string().describe("Database ID"), + overrides: personalViewOverridesSchema.nullable().optional(), + navigation: contentPersonalNavigationPatchSchema.optional(), + }) + .refine( + (args) => + (args.overrides !== undefined) !== (args.navigation !== undefined), + ), + run: async ({ databaseId, overrides, navigation }, ctx) => { if (!ctx?.userEmail) throw new Error("Not authenticated."); + const userEmail = ctx.userEmail; await assertContentDatabaseViewerAccess(databaseId); const key = personalDatabaseViewSettingKey(databaseId); - if (overrides) { - const requestedItemIds = personalSidebarOrderItemIds(overrides); - const validItemIds = new Set(); - const itemIdChunkSize = Math.max(1, bulkChunkSizeForColumnCount(1) - 1); - for (const itemIds of chunks(requestedItemIds, itemIdChunkSize)) { - const rows = await getDb() - .select({ id: schema.contentDatabaseItems.id }) - .from(schema.contentDatabaseItems) - .where( - and( - eq(schema.contentDatabaseItems.databaseId, databaseId), - inArray(schema.contentDatabaseItems.id, itemIds), - ), - ); - for (const row of rows) validItemIds.add(row.id); - } - overrides = normalizePersonalDatabaseViewOverrides( - overrides, - validItemIds, + const db = getDb(); + const mutateSetting = ( + updater: ( + tx: ReturnType, + current: Record | null, + ) => + | Promise<{ value: Record | null; result: T }> + | { value: Record | null; result: T }, + ) => + mutateContentUserSettingTransaction( + (callback) => db.transaction(callback), + userEmail, + key, + (tx, current) => + updater(tx as unknown as ReturnType, current), ); - await putUserSetting(ctx.userEmail, key, overrides); + if (navigation) { + const [database] = await db + .select({ + viewConfigJson: schema.contentDatabases.viewConfigJson, + systemRole: schema.contentDatabases.systemRole, + }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + const config = z + .object({ + views: z + .array( + z.object({ + id: z.string(), + sorts: z.array(sortSchema), + filters: z.array(filterSchema), + filterMode: z.enum(["and", "or"]).optional(), + }), + ) + .optional(), + }) + .parse(JSON.parse(database.viewConfigJson)); + const viewIds = new Set( + config.views?.map((view) => view.id) ?? ["default"], + ); + if ( + (navigation.activeViewId && !viewIds.has(navigation.activeViewId)) || + (navigation.sidebarOrder && + !viewIds.has(navigation.sidebarOrder.viewId)) + ) + fail("This View is unavailable.", { + statusCode: 404, + errorCode: "view_unavailable", + }); + const { value: saved } = await mutateSetting(async (tx, current) => { + const requestedItemIds = navigation.sidebarOrder + ? navigation.sidebarOrder.operation === "replace" + ? navigation.sidebarOrder.itemIds + : navigation.sidebarOrder.operation === "reorder-subset" + ? [ + ...navigation.sidebarOrder.itemIds, + ...navigation.sidebarOrder.previousItemIds, + ] + : [navigation.sidebarOrder.itemId] + : []; + const validItemIds = new Set(); + for (const itemIds of chunks( + requestedItemIds, + Math.max(1, bulkChunkSizeForColumnCount(1) - 1), + )) { + const rows = await tx + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, databaseId), + inArray(schema.contentDatabaseItems.id, itemIds), + ), + ); + for (const row of rows) validItemIds.add(row.id); + } + const migrated = migratePersonalDatabaseViewOverrides( + current, + databaseId, + database.systemRole, + ); + const currentOrder = navigation.sidebarOrder + ? (migrated?.views.find( + (view) => view.id === navigation.sidebarOrder?.viewId, + )?.sidebarOrder?.itemIds ?? []) + : []; + for (const itemIds of chunks( + currentOrder, + Math.max(1, bulkChunkSizeForColumnCount(1) - 1), + )) { + const rows = await tx + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, databaseId), + inArray(schema.contentDatabaseItems.id, itemIds), + ), + ); + for (const row of rows) validItemIds.add(row.id); + } + const replaceOrder = + navigation.sidebarOrder?.operation === "replace" + ? navigation.sidebarOrder + : null; + const incrementalOrder = + navigation.sidebarOrder?.operation === "prepend" || + navigation.sidebarOrder?.operation === "remove" + ? navigation.sidebarOrder + : null; + const subsetOrder = + navigation.sidebarOrder?.operation === "reorder-subset" + ? navigation.sidebarOrder + : null; + const patch = replaceOrder + ? { + ...navigation, + sidebarOrder: { + ...replaceOrder, + itemIds: [ + ...replaceOrder.itemIds.filter((id) => validItemIds.has(id)), + ...currentOrder.filter( + (id) => + validItemIds.has(id) && + !replaceOrder.itemIds.includes(id), + ), + ], + }, + } + : incrementalOrder && !validItemIds.has(incrementalOrder.itemId) + ? fail("This sidebar item is unavailable.", { + statusCode: 404, + errorCode: "item_unavailable", + }) + : subsetOrder && + [...subsetOrder.itemIds, ...subsetOrder.previousItemIds].some( + (id) => !validItemIds.has(id), + ) + ? fail("A reordered sidebar item is unavailable.", { + statusCode: 409, + errorCode: "item_unavailable", + }) + : navigation; + const value = { + ...applyContentPersonalNavigationPatch(migrated, patch, config.views), + }; + return { + value: value as unknown as Record, + result: undefined, + }; + }); + return { + databaseId, + overrides: personalViewOverridesSchema.parse(saved), + }; + } + if (overrides) { + const requested = overrides; + const { value } = await mutateSetting(async (tx, current) => { + const currentOverrides = + current === null ? null : personalViewOverridesSchema.parse(current); + const requestedItemIds = [ + ...new Set([ + ...personalSidebarOrderItemIds(requested), + ...(currentOverrides + ? personalSidebarOrderItemIds(currentOverrides) + : []), + ]), + ]; + const validItemIds = new Set(); + const itemIdChunkSize = Math.max(1, bulkChunkSizeForColumnCount(1) - 1); + for (const itemIds of chunks(requestedItemIds, itemIdChunkSize)) { + const rows = await tx + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, databaseId), + inArray(schema.contentDatabaseItems.id, itemIds), + ), + ); + for (const row of rows) validItemIds.add(row.id); + } + const normalized = normalizePersonalDatabaseViewOverrides( + requested, + validItemIds, + ); + return { + value: { + ...normalized, + views: normalized.views.map((view) => { + const currentOrder = + currentOverrides?.views.find( + (candidate) => candidate.id === view.id, + )?.sidebarOrder?.itemIds ?? []; + return { + ...view, + sidebarOrder: { + ...view.sidebarOrder, + itemIds: [ + ...view.sidebarOrder.itemIds, + ...currentOrder.filter( + (id) => + validItemIds.has(id) && + !view.sidebarOrder.itemIds.includes(id), + ), + ], + }, + }; + }), + } as unknown as Record, + result: undefined, + }; + }); + overrides = personalViewOverridesSchema.parse(value); } else { - await deleteUserSetting(ctx.userEmail, key); + await mutateSetting(() => ({ value: null, result: undefined })); } return { databaseId, overrides }; diff --git a/templates/content/actions/update-content-sidebar-state.ts b/templates/content/actions/update-content-sidebar-state.ts index ed8d25603dd..37c3173a199 100644 --- a/templates/content/actions/update-content-sidebar-state.ts +++ b/templates/content/actions/update-content-sidebar-state.ts @@ -1,27 +1,85 @@ import { defineAction } from "@agent-native/core/action"; -import { putUserSetting } from "@agent-native/core/settings"; +import { getUserSetting, mutateUserSetting } from "@agent-native/core/settings"; +import { z } from "zod"; +import { defaultContentSidebarSections } from "../shared/content-personal-navigation.js"; import { CONTENT_SIDEBAR_STATE_SETTING_KEY, + contentSidebarStateSettingKey, contentSidebarStateSchema, + normalizeContentSidebarState, } from "./_content-sidebar-state.js"; +const sidebarSectionPatchSchema = z.object({ + visible: z.boolean().optional(), + expanded: z.boolean().optional(), +}); +const contentSidebarSectionsPatchSchema = z.object({ + order: contentSidebarStateSchema.shape.sections + .unwrap() + .shape.order.optional(), + pinned: sidebarSectionPatchSchema.optional(), + recent: sidebarSectionPatchSchema.optional(), + files: sidebarSectionPatchSchema.optional(), +}); + export default defineAction({ description: "Persist the current user's Content sidebar expansion state.", - schema: contentSidebarStateSchema, + schema: contentSidebarStateSchema + .partial() + .required({ version: true }) + .extend({ + spaceId: z.string().min(1).max(256).optional(), + sectionsPatch: contentSidebarSectionsPatchSchema.optional(), + }), agentTool: false, - run: async (state, ctx) => { + run: async ({ spaceId, sectionsPatch, ...state }, ctx) => { if (!ctx?.userEmail) throw new Error("Not authenticated."); - const normalized = { - ...state, - expandedWorkspaceIds: [...new Set(state.expandedWorkspaceIds)], - expandedDocumentIds: [...new Set(state.expandedDocumentIds)], - }; - await putUserSetting( + const scopedKey = contentSidebarStateSettingKey(spaceId); + const legacy = + scopedKey === CONTENT_SIDEBAR_STATE_SETTING_KEY + ? null + : await getUserSetting( + ctx.userEmail, + CONTENT_SIDEBAR_STATE_SETTING_KEY, + ); + const saved = await mutateUserSetting( ctx.userEmail, - CONTENT_SIDEBAR_STATE_SETTING_KEY, - normalized, + scopedKey, + (current) => { + const normalized = normalizeContentSidebarState( + current ?? legacy, + spaceId, + ); + const sections = state.sections ?? normalized?.sections; + return normalizeContentSidebarState({ + ...normalized, + ...state, + ...(sectionsPatch + ? { + sections: { + ...(sections ?? defaultContentSidebarSections()), + ...(sectionsPatch.order + ? { order: sectionsPatch.order } + : {}), + pinned: { + ...(sections ?? defaultContentSidebarSections()).pinned, + ...sectionsPatch.pinned, + }, + recent: { + ...(sections ?? defaultContentSidebarSections()).recent, + ...sectionsPatch.recent, + }, + files: { + ...(sections ?? defaultContentSidebarSections()).files, + ...sectionsPatch.files, + }, + }, + } + : {}), + })!; + }, ); - return { state: normalized }; + return { state: normalizeContentSidebarState(saved, spaceId) }; }, }); diff --git a/templates/content/actions/update-document.db.test.ts b/templates/content/actions/update-document.db.test.ts index 71788de970e..d06246bb991 100644 --- a/templates/content/actions/update-document.db.test.ts +++ b/templates/content/actions/update-document.db.test.ts @@ -22,6 +22,7 @@ type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; let schema: Schema; let updateDocumentAction: typeof import("./update-document.js").default; +let updatePersonalViewAction: typeof import("./update-content-database-personal-view.js").default; let editDocumentAction: typeof import("./edit-document.js").default; let documentRevisionToken: typeof import("./_document-edit-mutation.js").documentRevisionToken; @@ -35,6 +36,9 @@ beforeAll(async () => { getDb = dbModule.getDb; schema = dbModule.schema; updateDocumentAction = (await import("./update-document.js")).default; + updatePersonalViewAction = ( + await import("./update-content-database-personal-view.js") + ).default; editDocumentAction = (await import("./edit-document.js")).default; ({ documentRevisionToken } = await import("./_document-edit-mutation.js")); const plugin = (await import("../server/plugins/db.js")).default; @@ -86,6 +90,246 @@ async function documentRow(documentId: string) { } describe("update-document compare-and-swap", () => { + it("prepends only a newly created favorite membership and removes its order reference on unpin", async () => { + const { getUserSetting } = await import("@agent-native/core/settings"); + const { favoritesSystemIds } = await import("./_content-favorites.js"); + const { personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js"); + const first = await createDocument({ title: "First" }); + const second = await createDocument({ title: "Second" }); + for (const id of [first, second]) { + await runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ id, isFavorite: true }), + ); + } + await runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ id: second, isFavorite: true }), + ); + const databaseId = favoritesSystemIds(OWNER).databaseId; + const memberships = await getDb() + .select({ + id: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + const secondMembership = memberships.find( + (item: any) => item.documentId === second, + )!.id; + const firstMembership = memberships.find( + (item: any) => item.documentId === first, + )!.id; + const settingKey = personalDatabaseViewSettingKey(databaseId); + expect(await getUserSetting(OWNER, settingKey)).toMatchObject({ + views: [ + { + sidebarOrder: { + mode: "custom", + itemIds: [secondMembership, firstMembership], + }, + }, + ], + }); + + await runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ id: second, isFavorite: false }), + ); + expect(await getUserSetting(OWNER, settingKey)).toMatchObject({ + views: [{ sidebarOrder: { itemIds: [firstMembership] } }], + }); + }); + + it("keeps favorite membership and order consistent under concurrent pin and unpin", async () => { + const { getUserSetting } = await import("@agent-native/core/settings"); + const { favoritesSystemIds } = await import("./_content-favorites.js"); + const { personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js"); + const documentId = await createDocument({ title: "Concurrent favorite" }); + const invoke = (isFavorite: boolean) => + runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ id: documentId, isFavorite }), + ); + + await Promise.all([invoke(true), invoke(true)]); + const databaseId = favoritesSystemIds(OWNER).databaseId; + let memberships = await getDb() + .select({ + id: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + expect( + memberships.filter((row: any) => row.documentId === documentId), + ).toHaveLength(1); + + await Promise.all([invoke(true), invoke(false)]); + memberships = await getDb() + .select({ + id: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + const membershipIds = new Set(memberships.map((row: any) => row.id)); + const setting = await getUserSetting( + OWNER, + personalDatabaseViewSettingKey(databaseId), + ); + const references = ((setting?.views as any[]) ?? []).flatMap( + (view) => view.sidebarOrder?.itemIds ?? [], + ); + expect(references.every((id: string) => membershipIds.has(id))).toBe(true); + }); + + it("preserves a concurrent pin while an ordinary personal reorder commits", async () => { + const { getUserSetting } = await import("@agent-native/core/settings"); + const { favoritesSystemIds } = await import("./_content-favorites.js"); + const { personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js"); + const first = await createDocument({ title: "Reorder first" }); + const second = await createDocument({ title: "Reorder second" }); + const concurrent = await createDocument({ title: "Concurrent pin" }); + for (const id of [first, second]) { + await runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ id, isFavorite: true }), + ); + } + const databaseId = favoritesSystemIds(OWNER).databaseId; + const memberships = await getDb() + .select({ + id: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + const membershipByDocument = new Map( + memberships.map((item: any) => [item.documentId, item.id]), + ); + const requestedOrder = [ + membershipByDocument.get(first)!, + membershipByDocument.get(second)!, + ]; + + await Promise.all([ + runWithRequestContext({ userEmail: OWNER }, () => + updatePersonalViewAction.run( + { + databaseId, + navigation: { + sidebarOrder: { + viewId: "default", + mode: "custom", + itemIds: requestedOrder, + }, + }, + }, + { userEmail: OWNER }, + ), + ), + runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ id: concurrent, isFavorite: true }), + ), + ]); + + const finalMemberships = await getDb() + .select({ + id: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + const concurrentMembership = finalMemberships.find( + (item: any) => item.documentId === concurrent, + )!.id; + const setting = await getUserSetting( + OWNER, + personalDatabaseViewSettingKey(databaseId), + ); + const order = (setting?.views as any[])[0].sidebarOrder.itemIds as string[]; + expect(order).toEqual( + expect.arrayContaining([...requestedOrder, concurrentMembership]), + ); + const finalMembershipIds = new Set( + finalMemberships.map((item: any) => item.id), + ); + expect(order.every((id) => finalMembershipIds.has(id))).toBe(true); + expect(order.indexOf(requestedOrder[0])).toBeLessThan( + order.indexOf(requestedOrder[1]), + ); + }); + + it("rolls back a failed favorite setting write without damaging a competing success", async () => { + const { getUserSetting } = await import("@agent-native/core/settings"); + const { favoritesSystemIds } = await import("./_content-favorites.js"); + const { personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js"); + const failedCandidate = await createDocument({ title: "Failed candidate" }); + const successfulCandidate = await createDocument({ + title: "Successful candidate", + }); + const db = getDb(); + const originalTransaction = db.transaction.bind(db); + let failOneSettingWrite = true; + const transaction = vi + .spyOn(db, "transaction") + .mockImplementation(async (callback: any, config?: any) => + originalTransaction(async (tx: any) => { + let executeCount = 0; + const wrapped = Object.create(tx); + wrapped.execute = async (...executeArgs: any[]) => { + executeCount += 1; + if (failOneSettingWrite && executeCount === 3) { + failOneSettingWrite = false; + throw new Error("simulated setting write failure"); + } + return tx.execute(...executeArgs); + }; + return callback(wrapped); + }, config), + ); + let results: PromiseSettledResult[]; + try { + results = await Promise.allSettled( + [failedCandidate, successfulCandidate].map((id) => + runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ id, isFavorite: true }), + ), + ), + ); + } finally { + transaction.mockRestore(); + } + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + + const databaseId = favoritesSystemIds(OWNER).databaseId; + const memberships = await db + .select({ + id: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + const membershipIds = new Set(memberships.map((row: any) => row.id)); + const setting = await getUserSetting( + OWNER, + personalDatabaseViewSettingKey(databaseId), + ); + const references = ((setting?.views as any[]) ?? []).flatMap( + (view) => view.sidebarOrder?.itemIds ?? [], + ); + expect(references.every((id: string) => membershipIds.has(id))).toBe(true); + expect( + memberships.filter((row: any) => + [failedCandidate, successfulCandidate].includes(row.documentId), + ), + ).toHaveLength(1); + }); it("initializes an empty body through the externally callable edit action", async () => { const documentId = await createDocument({ title: "Keep this title", @@ -688,7 +932,7 @@ describe("update-document compare-and-swap", () => { expect(ownerEvents).toHaveLength(0); }); - it("preserves mixed updates without exposing their inputs to the owner", async () => { + it("preserves separate content and favorite updates without exposing their inputs to the owner", async () => { const documentId = await createDocument({ content: "owner body" }); await getDb() .insert(schema.documentShares) @@ -707,7 +951,6 @@ describe("update-document compare-and-swap", () => { { id: documentId, content: "collaborator body", - isFavorite: true, }, { caller: "frontend", @@ -716,6 +959,16 @@ describe("update-document compare-and-swap", () => { }, ), ); + await runWithRequestContext({ userEmail: EDITOR }, () => + updateDocumentAction.run( + { id: documentId, isFavorite: true }, + { + caller: "frontend", + actionName: "update-document", + userEmail: EDITOR, + }, + ), + ); const { queryAuditEvents } = await import("@agent-native/core/audit"); const ownerEvents = await queryAuditEvents( { userEmail: OWNER }, diff --git a/templates/content/actions/update-document.test.ts b/templates/content/actions/update-document.test.ts index de2faff7c69..8af2b3e9ebc 100644 --- a/templates/content/actions/update-document.test.ts +++ b/templates/content/actions/update-document.test.ts @@ -7,6 +7,14 @@ import { } from "./update-document"; describe("update document", () => { + it("rejects favorite changes combined with document fields before mutation", async () => { + const action = (await import("./update-document")).default; + await expect( + action.run({ id: "document", isFavorite: true, title: "Changed" }), + ).rejects.toMatchObject({ + errorCode: "FAVORITE_UPDATE_MUST_BE_SEPARATE", + }); + }); it("rejects stale empty body saves but allows a current legitimate clear", () => { expect( shouldRejectStaleEmptyBodySave({ diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index a2d92dfac52..50c5d0c7496 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -28,6 +28,7 @@ import { parseDocumentHideFromSearch, } from "../server/lib/documents.js"; import type { DocumentUpdateResponse } from "../shared/api.js"; +import { applyContentPersonalNavigationPatch } from "../shared/content-personal-navigation-patch.js"; import { inspectNfmFidelity } from "../shared/nfm.js"; import { lockPrimaryBlocksFields, @@ -35,8 +36,13 @@ import { } from "./_blocks-field-identity.js"; import { BUILDER_CMS_BODY_CONTENT_KEY } from "./_builder-cms-source-adapter.js"; import { reconcileInlineDatabasesForDocument } from "./_content-database-lifecycle.js"; +import { + migratePersonalDatabaseViewOverrides, + personalDatabaseViewSettingKey, +} from "./_content-database-personal-view.js"; import { favoriteDocumentIds, + favoritesSystemIds, setFavoriteMembership, } from "./_content-favorites.js"; import { provisionContentSpaces } from "./_content-spaces.js"; @@ -50,6 +56,7 @@ import { resolveDocumentAccessForMutation, } from "./_document-mutation-access.js"; import { serializeDocumentSource } from "./_document-source.js"; +import { mutateContentUserSettingTransaction } from "./_user-setting-transaction.js"; // Not (yet) part of the shared API surface — kept local to avoid touching // shared/api.ts, which another workstream owns concurrently. Structural @@ -195,6 +202,51 @@ function canCommentRole(role: string) { ); } +async function setFavoriteAndOrder(args: { + db: ReturnType; + userEmail: string; + documentId: string; + favorite: boolean; + now: string; +}) { + const favoritesDatabaseId = favoritesSystemIds(args.userEmail).databaseId; + const settingName = personalDatabaseViewSettingKey(favoritesDatabaseId); + return mutateContentUserSettingTransaction( + (callback) => args.db.transaction(callback), + args.userEmail, + settingName, + async (tx, current) => { + const migrated = migratePersonalDatabaseViewOverrides( + current, + favoritesDatabaseId, + "favorites", + ); + const activeViewId = migrated?.activeViewId ?? "default"; + const membership = await setFavoriteMembership({ + db: tx as unknown as ReturnType, + userEmail: args.userEmail, + documentId: args.documentId, + favorite: args.favorite, + now: args.now, + }); + return { + value: applyContentPersonalNavigationPatch( + migrated, + { + sidebarOrder: { + operation: args.favorite ? "prepend" : "remove", + viewId: activeViewId, + itemId: membership.membershipId, + }, + }, + [{ id: activeViewId, sorts: [], filters: [], filterMode: "and" }], + ) as unknown as Record, + result: membership, + }; + }, + ); +} + function builderBodyWithoutImageSourceComponentMarkers( content: string | null | undefined, ) { @@ -414,6 +466,15 @@ export default defineAction({ ): Promise => { const id = args.id; if (!id) throw new Error("--id is required"); + if (args.isFavorite !== undefined && !isFavoriteOnlyUpdate(args)) { + throw new ActionContractError( + "Favorite changes must be submitted separately from document field changes.", + { + errorCode: "FAVORITE_UPDATE_MUST_BE_SEPARATE", + statusCode: 400, + }, + ); + } if ( args.title !== undefined && args.content !== undefined && @@ -550,7 +611,8 @@ export default defineAction({ contentChanged || iconChanged || favoriteChanged || - descriptionChanged; + descriptionChanged || + args.isFavorite === false; let softDeletedDatabaseIds: string[] = []; let creativeContext: @@ -575,7 +637,7 @@ export default defineAction({ let contentCasConflict = false; let committedContentChanged = false; let committedContentBefore = existing.content; - await db.transaction(async (tx) => { + const mutate = async (tx: any) => { await tx .select({ id: schema.documents.id }) .from(schema.documents) @@ -716,16 +778,6 @@ export default defineAction({ committedContentChanged = lockedContentChanged; committedContentBefore = historyBefore.content; - if (favoriteChanged) { - await setFavoriteMembership({ - db: tx, - userEmail: requestUserEmail as string, - documentId: id, - favorite: args.isFavorite as boolean, - now: updatedAt, - }); - } - if (lockedTitleChanged && args.title !== undefined) { await propagateDocumentTitle({ db: tx as unknown as ReturnType, @@ -757,7 +809,18 @@ export default defineAction({ now: updatedAt, }); } - }); + }; + if (favoriteChanged || args.isFavorite === false) { + await setFavoriteAndOrder({ + db, + userEmail: requestUserEmail as string, + documentId: id, + favorite: args.isFavorite as boolean, + now: nextDocumentUpdatedAt(existing.updatedAt), + }); + } else { + await db.transaction(mutate); + } if (contentCasConflict) { // Someone else's write landed after the caller's snapshot. Don't diff --git a/templates/content/app/components/editor/DocumentDatabase.tsx b/templates/content/app/components/editor/DocumentDatabase.tsx index 8f840ae04cb..54fca7a4a26 100644 --- a/templates/content/app/components/editor/DocumentDatabase.tsx +++ b/templates/content/app/components/editor/DocumentDatabase.tsx @@ -9,6 +9,7 @@ interface DocumentDatabaseProps { document: Document; canEdit: boolean; viewId?: string | null; + foreground?: boolean; onExportContextChange?: (context: DatabaseExportContext | null) => void; } @@ -16,6 +17,7 @@ export function DocumentDatabase({ document, canEdit, viewId, + foreground, onExportContextChange, }: DocumentDatabaseProps) { const databaseId = document.database?.id; @@ -27,6 +29,7 @@ export function DocumentDatabase({ databaseDocumentId={document.id} canEdit={canEdit} viewId={viewId} + foreground={foreground} onExportContextChange={onExportContextChange} /> ); diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 9907796f45a..de91f101a05 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -55,7 +55,7 @@ import { useState, } from "react"; import type { ClipboardEvent, MutableRefObject, ReactNode } from "react"; -import { useLocation, useNavigate } from "react-router"; +import { Navigate, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { @@ -83,6 +83,7 @@ import { useDeleteContentDatabase, useProcessBuilderBodyHydration, } from "@/hooks/use-content-database"; +import { useRecordContentVisit } from "@/hooks/use-content-recent"; import { useContentSpaces, type ContentSpaceSummary, @@ -259,6 +260,7 @@ interface DocumentEditorProps { databaseId?: string | null; databaseDocumentId?: string | null; viewId?: string | null; + foreground?: boolean; } export interface PageEditorSession { @@ -543,6 +545,7 @@ export function DocumentEditor({ databaseId, databaseDocumentId, viewId, + foreground = false, }: DocumentEditorProps) { return ( ); @@ -571,6 +575,7 @@ export function PageEditorSurface({ databaseId, databaseDocumentId, viewId, + foreground = false, host, onSessionChange, onDelete, @@ -641,6 +646,18 @@ export function PageEditorSurface({ }); admittedDocumentIdRef.current = loadState.admittedDocumentId; + useRecordContentVisit( + { documentId }, + foreground && + host === "page" && + !viewId && + !!document && + !document.database && + !isError && + isFetchedAfterMount && + loadState.view === "editor", + ); + async function retryDocumentQuery() { setManualRetryDocumentId(documentId); try { @@ -699,6 +716,18 @@ export function PageEditorSurface({ ); } + if ( + viewId && + document && + (!document.database || document.database.id !== databaseId) + ) { + return host === "page" ? ( + + ) : ( + + ); + } + // If we have a doc (real or optimistic from create) render the editor — // an `isError` blip during a just-fired create shouldn't flash "not found". // A database/list snapshot can optimistically seed the document cache with a @@ -721,6 +750,9 @@ export function PageEditorSurface({ })} documentId={documentId} document={document} + foreground={ + foreground && host === "page" && !isError && isFetchedAfterMount + } databaseId={databaseId} databaseDocumentId={databaseDocumentId} viewId={viewId} @@ -1031,6 +1063,7 @@ interface DocumentEditorBodyProps { onDelete?: () => Promise; focusTitle: boolean; onTitleFocused?: () => void; + foreground?: boolean; } type PendingDocumentSave = { @@ -1471,6 +1504,7 @@ function PageEditorSessionBody({ onDelete, focusTitle, onTitleFocused, + foreground = false, }: DocumentEditorBodyProps) { const acknowledgedDocumentRef = useRef(null); const resolvedDocument = resolveAcknowledgedDocumentSnapshot({ @@ -1489,17 +1523,32 @@ function PageEditorSessionBody({ databaseDocumentId, }); useEffect(() => { - if (host !== "page") return; + if (host !== "page" || document.database?.systemRole) return; void rememberContentLandingDocument( - documentId, - currentDocumentRef.current?.title, + { + documentId, + ...(currentDocumentRef.current?.title?.trim() + ? { title: currentDocumentRef.current.title } + : {}), + ...(databaseId ? { databaseId } : {}), + ...(viewId ? { viewId } : {}), + }, + document.spaceId ?? undefined, ).catch((error) => { toast.error(t("landing.saveFailed"), { description: error instanceof Error ? error.message : t("empty.genericError"), }); }); - }, [documentId, host, t]); + }, [ + databaseId, + document.database?.systemRole, + document.spaceId, + documentId, + host, + viewId, + t, + ]); const updateDocument = useUpdateDocument(); const resolvePreviewDocumentDraft = useResolvePreviewDocumentDraft(); const updatePreviewDocumentDraft = useUpdatePreviewDocumentDraft(); @@ -4755,7 +4804,7 @@ function PageEditorSessionBody({ { requestSource: "content-breadcrumb" }, ), persistSelection: setStoredSpaceId, - openFiles: () => navigate(`/page/${targetId}`, { flushSync: true }), + openSpace: () => navigate(`/page/${targetId}`, { flushSync: true }), }), ) .catch((error) => { @@ -5375,6 +5424,7 @@ function PageEditorSessionBody({
{ expect(toolbarSource).toContain("editor.toolbar.pin"); }); - it("uses a pin glyph for pinning in the page menu and sidebar", () => { + it("uses a pin glyph for the page and sidebar row pin commands", () => { for (const source of [ toolbarSource, - sidebarSource, treeItemSource, databaseSidebarSource, ]) { 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 3f2963a31f5..8020ba77351 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 @@ -17,7 +17,7 @@ describe("Files database local-folder source entry", () => { 'isFilesDatabase={document.database?.systemRole === "files"}', ); expect(databaseView).toContain( - "{ systemRole: document.database?.systemRole }", + "systemRole: document.database?.systemRole,", ); expect(databaseView).toContain('label={t("sidebar.localFolder")}'); expect(databaseView).toContain( diff --git a/templates/content/app/components/editor/database/DatabaseView.recent.test.ts b/templates/content/app/components/editor/database/DatabaseView.recent.test.ts new file mode 100644 index 00000000000..e4d62425f40 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseView.recent.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + createDatabaseView, + normalizeClientDatabaseViewConfig, + resolveRequestedDatabaseView, + databaseViewHasPersonalQueryChanges, + databaseViewConfigWithSavedQueryState, +} from "./DatabaseView"; + +describe("exact personal View navigation", () => { + const table = createDatabaseView("table"); + const board = createDatabaseView("Board", "board", {}, "board"); + const saved = normalizeClientDatabaseViewConfig({ + activeViewId: table.id, + views: [table, board], + }); + + it("opens the requested View without changing the shared default", () => { + const personal = resolveRequestedDatabaseView(saved, board.id)!; + expect(personal.activeViewId).toBe(board.id); + expect(saved.activeViewId).toBe(table.id); + expect(databaseViewHasPersonalQueryChanges(personal, saved)).toBe(true); + expect(databaseViewConfigWithSavedQueryState(personal, saved)).toEqual( + saved, + ); + }); + + it("keeps a missing exact View unavailable instead of opening the default", () => { + expect(resolveRequestedDatabaseView(saved, "missing-view")).toBeNull(); + }); + + it("preserves the effective personal View on an ordinary Page open", () => { + const personal = resolveRequestedDatabaseView(saved, board.id)!; + expect(resolveRequestedDatabaseView(personal, null)).toBe(personal); + }); +}); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 1c3afef15d9..368506c390a 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -45,6 +45,7 @@ import { type DocumentPropertyType, type DocumentPropertyValue, } from "@shared/api"; +import { contentRecentHref } from "@shared/content-personal-navigation"; import { contentDatabaseFormQuestions } from "@shared/database-form"; import { applyContentDatabaseTableQuery } from "@shared/database-query"; import { @@ -204,6 +205,8 @@ import { type ContentDatabaseViewSaveResponse, writeBuilderAttachPreviewToCache, } from "@/hooks/use-content-database"; +import { useUpdateContentPersonalNavigation } from "@/hooks/use-content-personal-navigation"; +import { useRecordContentVisit } from "@/hooks/use-content-recent"; import { useContentSpaces, useDeleteContentSpace, @@ -315,6 +318,7 @@ export interface DatabaseViewProps { canEdit?: boolean; isActive?: boolean; viewId?: string | null; + foreground?: boolean; onExportContextChange?: (context: DatabaseExportContext | null) => void; } @@ -827,6 +831,7 @@ export function DatabaseView({ canEdit = true, isActive, viewId, + foreground = false, onExportContextChange, }: DatabaseViewProps) { const { data: document } = useDocument(databaseDocumentId); @@ -844,6 +849,7 @@ export function DatabaseView({ canEdit={effectiveCanEdit} isActive={isActive ?? renderMode === "page"} viewId={viewId} + foreground={foreground} onExportContextChange={onExportContextChange} /> ); @@ -857,7 +863,8 @@ function DatabaseTable({ renderMode, canEdit, isActive, - viewId, + viewId: exactRequestedViewId, + foreground, onExportContextChange, }: { document: Document; @@ -868,6 +875,7 @@ function DatabaseTable({ canEdit: boolean; isActive: boolean; viewId?: string | null; + foreground: boolean; onExportContextChange?: (context: DatabaseExportContext | null) => void; }) { const t = useT(); @@ -880,6 +888,9 @@ function DatabaseTable({ null, ); const [searchQuery, setSearchQuery] = useState(""); + const [hydratedDatabaseId, setHydratedDatabaseId] = useState( + null, + ); const [viewConfig, setViewConfig] = useState( defaultDatabaseViewConfig(), ); @@ -915,7 +926,12 @@ function DatabaseTable({ document.id, databaseRequestItemLimit, tableQuery, - { systemRole: document.database?.systemRole }, + { + systemRole: document.database?.systemRole, + ...(foreground && renderMode === "page" && isActive + ? { refetchOnMount: "always" as const } + : {}), + }, ); // A deleted/missing database resolves to the unavailable union (no // `database` field) — treat it as no data; the inline-block wrapper owns @@ -965,7 +981,7 @@ function DatabaseTable({ ); const serializedSearchParams = searchParams.toString(); const requestedViewId = requestedDatabaseViewId( - viewId, + exactRequestedViewId, searchParams.get(viewSelectionSearchParam), ); const personalViewDatabaseId = data?.database.id ?? null; @@ -1885,7 +1901,8 @@ function DatabaseTable({ { requestSource: "content-workspaces-database" }, ), persistSelection: setStoredSpaceId, - openFiles: (documentId) => navigate(`/page/${documentId}`), + openSpace: (spaceId) => + navigate(`/home?spaceId=${encodeURIComponent(spaceId)}`), }); }) .catch((error) => { @@ -2084,7 +2101,7 @@ function DatabaseTable({ (current) => databaseSearchParamsWithSelectedView( current, - viewSelectionSearchParam, + exactRequestedViewId ? "viewId" : viewSelectionSearchParam, normalized.activeViewId, ), { replace: true }, @@ -2734,13 +2751,18 @@ function DatabaseTable({ const nextSavedViewConfig = normalizeClientDatabaseViewConfig( data.database.viewConfig, ); - const nextViewConfig = applyPersonalDatabaseViewOverrides( + const personalViewConfig = applyPersonalDatabaseViewOverrides( nextSavedViewConfig, normalizePersonalDatabaseViewOverrides(personalView.data?.overrides), ); + if ( + exactRequestedViewId && + !resolveRequestedDatabaseView(personalViewConfig, exactRequestedViewId) + ) + return; const reconciled = reconcileDatabaseViewSelection({ savedViewConfig: nextSavedViewConfig, - viewConfig: nextViewConfig, + viewConfig: personalViewConfig, requestedViewId, }); if (!reconciled.requestedViewExists) { @@ -2758,6 +2780,7 @@ function DatabaseTable({ data.database.id, reconciled.viewConfig, ); + setHydratedDatabaseId(data.database.id); const mutationContract = data.mutationContract; if (mutationContract && data.configurationRevision) { const { authorityScope: _authorityScope, ...target } = @@ -2793,6 +2816,7 @@ function DatabaseTable({ personalView.data?.overrides, personalView.isLoading, requestedViewId, + exactRequestedViewId, renderMode, serializedSearchParams, setSearchParams, @@ -2878,6 +2902,63 @@ function DatabaseTable({ viewConfig, ]); + const exactViewUnavailable = + !!exactRequestedViewId && + (database.isError || + isContentDatabaseUnavailable(database.data) || + (!!data && + !resolveRequestedDatabaseView( + normalizeClientDatabaseViewConfig(data.database.viewConfig), + exactRequestedViewId, + ))); + useRecordContentVisit( + { documentId: document.id, databaseId, viewId: activeView.id }, + foreground && + renderMode === "page" && + isActive && + database.isSuccess && + database.isFetchedAfterMount && + !attachPreviewActive && + !personalView.isLoading && + !personalView.isError && + !!data && + hydratedDatabaseId === databaseId && + !exactViewUnavailable && + (!requestedViewId || activeView.id === requestedViewId) && + normalizeClientDatabaseViewConfig(data.database.viewConfig).views.some( + (view) => view.id === activeView.id, + ), + ); + + const updatePersonalNavigation = + useUpdateContentPersonalNavigation(databaseId); + function selectPersonalView(viewId: string) { + const next = selectDatabaseView(viewConfig, viewId); + setPersonalQueryDirty( + databaseViewHasPersonalQueryChanges(next, savedViewConfig), + ); + setViewConfig(next); + updatePersonalNavigation.mutate( + { databaseId, navigation: { activeViewId: viewId } }, + { onError: () => toast.error(dbText("failedToSaveView")) }, + ); + if (foreground && renderMode === "page" && isActive) { + navigate( + contentRecentHref({ documentId: document.id, databaseId, viewId }), + ); + } else { + setSearchParams( + (current) => + databaseSearchParamsWithSelectedView( + current, + viewSelectionSearchParam, + viewId, + ), + { replace: true }, + ); + } + } + function resizeColumn( key: ColumnKey, defaultWidth: number, @@ -2911,6 +2992,14 @@ function DatabaseTable({ globalThis.document.addEventListener("pointerup", handlePointerUp); } + if (exactViewUnavailable) { + return ( +
+ {t("empty.documentUnavailable")} +
+ ); + } + return (
@@ -2918,6 +3007,7 @@ function DatabaseTable({ viewConfig={viewConfig} canEdit={effectiveCanEdit} onViewConfigChange={handleViewConfigChange} + onViewSelect={selectPersonalView} /> view.id === requestedViewId)) return null; + return selectDatabaseView(config, requestedViewId); +} + export function addDatabaseView( config: ContentDatabaseViewConfig, name: string, @@ -15009,10 +15108,12 @@ function DatabaseViewTabs({ viewConfig, canEdit, onViewConfigChange, + onViewSelect, }: { viewConfig: ContentDatabaseViewConfig; canEdit: boolean; onViewConfigChange: (viewConfig: ContentDatabaseViewConfig) => void; + onViewSelect: (viewId: string) => void; }) { const normalized = normalizeClientDatabaseViewConfig(viewConfig); const [newViewName, setNewViewName] = useState(""); @@ -15207,7 +15308,7 @@ function DatabaseViewTabs({ return; } if (!active) { - onViewConfigChange(selectDatabaseView(normalized, view.id)); + onViewSelect(view.id); } }} onContextMenu={(event) => { diff --git a/templates/content/app/components/editor/database/paged-sidebar.test.tsx b/templates/content/app/components/editor/database/paged-sidebar.test.tsx new file mode 100644 index 00000000000..f084c15e063 --- /dev/null +++ b/templates/content/app/components/editor/database/paged-sidebar.test.tsx @@ -0,0 +1,355 @@ +// @vitest-environment happy-dom + +import type { + ContentDatabaseItem, + ContentDatabaseNavigationItem, + ContentDatabaseNavigationPageResponse, + Document, +} from "@shared/api"; +import { act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TooltipProvider } from "@/components/ui/tooltip"; + +const useActionQuery = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core/client/hooks", () => ({ useActionQuery })); + +import { + navigationItemAsDatabaseItem, + PagedContentFilesSidebarView, +} from "./sidebar"; + +function navigationItem( + id: string, + parentId: string | null = null, + hasChildren = false, +): ContentDatabaseNavigationItem { + return { + membershipId: `membership-${id}`, + membershipPosition: Number(id.replace(/\D/g, "")) || 0, + documentId: id, + parentId, + title: `Page ${id}`, + icon: null, + type: "page", + hasChildren, + spaceId: "space", + sourceKind: null, + isFavorite: false, + canEdit: true, + canManage: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function page( + items: ContentDatabaseNavigationItem[], + nextCursor: string | null = null, +): ContentDatabaseNavigationPageResponse { + return { + items, + pagination: { + limit: 20, + hasMore: nextCursor !== null, + nextCursor, + }, + }; +} + +function Harness({ + activeDocumentId, + activePathDocuments, + onDeleteItem, + onToggleFavorite, +}: { + activeDocumentId?: string; + activePathDocuments?: Document[]; + onDeleteItem?: (item: ContentDatabaseItem) => void; + onToggleFavorite?: (item: ContentDatabaseItem) => void; +}) { + const [expanded, setExpanded] = useState>(new Set()); + return ( + + + + setExpanded((current) => { + const next = new Set(current); + if (open) next.add(id); + else next.delete(id); + return next; + }) + } + documentMetadata={new Map()} + activePathDocuments={activePathDocuments} + onCreateChildPage={() => {}} + onDeleteItem={onDeleteItem} + onToggleFavorite={onToggleFavorite} + navigationLabel="Files" + untitledLabel="Untitled" + /> + + + ); +} + +async function renderHarness(props: Parameters[0] = {}) { + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => root.render()); + return { container, root }; +} + +describe("PagedContentFilesSidebarView", () => { + beforeEach(() => { + useActionQuery.mockReset(); + }); + + it("shows 20 roots and appends the 21st through the opaque cursor", async () => { + useActionQuery.mockImplementation((_name, args) => ({ + data: args.navigation.cursor + ? page([navigationItem("root-21")]) + : page( + Array.from({ length: 20 }, (_, index) => + navigationItem(`root-${index + 1}`), + ), + "root-cursor", + ), + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + })); + const { container, root } = await renderHarness(); + + expect(container.querySelectorAll("a")).toHaveLength(20); + expect(container.textContent).toContain("Show more"); + const showMore = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent === "Show more"); + expect(showMore?.style.gridTemplateColumns).toBe("28px minmax(0, 1fr)"); + expect(showMore?.className).toContain("min-h-[38px]"); + expect(showMore?.className).toContain("hover:bg-transparent"); + expect(showMore?.className).toContain("font-medium"); + await act(async () => + Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Show more") + ?.click(), + ); + expect(container.querySelectorAll("a")).toHaveLength(21); + expect(container.textContent).toContain("Page root-21"); + expect(useActionQuery).toHaveBeenCalledWith( + "query-content-database-items", + expect.objectContaining({ + limit: 20, + navigation: expect.objectContaining({ cursor: "root-cursor" }), + }), + ); + expect( + container.querySelector("[data-radix-scroll-area-viewport]"), + ).toBeNull(); + + await act(async () => root.unmount()); + }); + + it("composes overlapping server pages into 25 unique sequential roots", async () => { + const roots = Array.from({ length: 25 }, (_, index) => + navigationItem(`root-${String(index + 1).padStart(2, "0")}`), + ); + useActionQuery.mockImplementation((_name, args) => ({ + data: args.navigation.cursor + ? page([...roots.slice(10, 20), ...roots.slice(20)]) + : page(roots.slice(0, 20), "root-cursor"), + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + })); + const { container, root } = await renderHarness(); + + await act(async () => + Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Show more") + ?.click(), + ); + + const ids = Array.from(container.querySelectorAll("a")) + .map((link) => link.getAttribute("href")) + .filter((href): href is string => href !== null); + expect(ids).toEqual(roots.map((item) => `/page/${item.documentId}`)); + expect(new Set(ids).size).toBe(25); + expect(container.textContent).not.toContain("Show more"); + + await act(async () => root.unmount()); + }); + + it("lazy-loads 20 immediate children and uses server hasChildren", async () => { + useActionQuery.mockImplementation((_name, args) => { + if (args.navigation.parentId === "root") { + return { + data: args.navigation.cursor + ? page([navigationItem("child-21", "root")]) + : page( + Array.from({ length: 20 }, (_, index) => + navigationItem(`child-${index + 1}`, "root"), + ), + "child-cursor", + ), + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + }; + } + return { + data: page([navigationItem("root", null, true)]), + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + }; + }); + const { container, root } = await renderHarness(); + + expect(useActionQuery).toHaveBeenCalledTimes(1); + const expand = container.querySelector( + '[aria-label="Expand Page root"]', + ); + expect(expand).not.toBeNull(); + await act(async () => expand?.click()); + expect(useActionQuery).toHaveBeenCalledWith( + "query-content-database-items", + expect.objectContaining({ + navigation: expect.objectContaining({ parentId: "root" }), + }), + ); + expect(container.querySelectorAll('a[href^="/page/child-"]')).toHaveLength( + 20, + ); + expect(container.textContent).toContain("Show more"); + expect( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Show more", + )?.style.gridTemplateColumns, + ).toBe("46px minmax(0, 1fr)"); + await act(async () => + Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Show more") + ?.click(), + ); + expect(container.querySelectorAll('a[href^="/page/child-"]')).toHaveLength( + 21, + ); + expect(container.textContent).toContain("Page child-21"); + + await act(async () => root.unmount()); + }); + + it("keeps failed pages recoverable with Retry", async () => { + const refetch = vi.fn(); + useActionQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + isFetching: false, + refetch, + }); + const { container, root } = await renderHarness(); + + const retry = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Retry", + ); + expect(retry).not.toBeUndefined(); + await act(async () => retry?.click()); + expect(refetch).toHaveBeenCalledOnce(); + + await act(async () => root.unmount()); + }); + + it("enables authorized paged-row actions without active-path metadata", async () => { + const onDeleteItem = vi.fn(); + const onToggleFavorite = vi.fn(); + useActionQuery.mockReturnValue({ + data: page([navigationItem("ordinary-row")]), + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + }); + const { container, root } = await renderHarness({ + onDeleteItem, + onToggleFavorite, + }); + + const menu = container.querySelector( + 'button[aria-haspopup="menu"]', + ); + expect(menu).not.toBeNull(); + expect(onToggleFavorite).not.toHaveBeenCalled(); + expect( + navigationItemAsDatabaseItem(navigationItem("ordinary-row"), undefined) + .document.spaceId, + ).toBe("space"); + + await act(async () => root.unmount()); + }); + + it("reveals an off-page active path without requesting preceding pages", async () => { + useActionQuery.mockImplementation((_name, args) => ({ + data: + args.navigation.parentId === "off-page-root" + ? page([]) + : page([navigationItem("first-root")], "next-root-page"), + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + })); + const rootDocument = { + id: "off-page-root", + parentId: null, + title: "Off-page root", + content: "", + icon: null, + position: 99, + isFavorite: false, + hideFromSearch: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } satisfies Document; + const activeDocument = { + ...rootDocument, + id: "active-child", + parentId: rootDocument.id, + title: "Active child", + } satisfies Document; + const { container, root } = await renderHarness({ + activeDocumentId: activeDocument.id, + activePathDocuments: [rootDocument, activeDocument], + }); + + expect(container.textContent).toContain("Off-page root"); + const expand = container.querySelector( + '[aria-label="Expand Off-page root"]', + ); + await act(async () => expand?.click()); + expect(container.textContent).toContain("Active child"); + expect(container.querySelector('[aria-current="page"]')).not.toBeNull(); + expect( + useActionQuery.mock.calls.some( + ([, args]) => args.navigation.cursor === "next-root-page", + ), + ).toBe(false); + + await act(async () => root.unmount()); + }); +}); diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 9e7df554097..695ba3cd11e 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -119,7 +119,7 @@ describe("DatabaseSidebarView", () => { ).toEqual(["canonical-parent-a", "canonical-parent-b"]); }); - it("disables manual reorder while the active saved view has sorts", () => { + it("disables legacy manual reorder without a personal order while the active saved view has sorts", () => { const markup = renderToStaticMarkup( @@ -154,7 +154,6 @@ describe("DatabaseSidebarView", () => { } overrides={null} isLoading={false} - sidebarOrder={{ mode: "custom", itemIds: ["item-first"] }} manualReorder={{ onReorder: () => {}, labels: { @@ -177,9 +176,83 @@ describe("DatabaseSidebarView", () => { ); expect(markup).not.toContain("Drag First"); + expect(markup).not.toContain('aria-roledescription="sortable"'); expect(markup).toContain('role="link"'); }); + it("allows personal custom reordering while retaining inherited filters and overriding inherited sorts", () => { + const data = { + database: { + viewConfig: { + version: 1, + activeViewId: "default", + views: [ + { + id: "default", + name: "Table", + type: "table", + filters: [ + { + key: "name", + label: "Name", + operator: "contains", + value: "Keep", + }, + ], + sorts: [{ key: "name", label: "Name", direction: "asc" }], + filterMode: "and", + }, + ], + }, + }, + items: [ + item("alpha", "Keep Alpha"), + item("beta", "Keep Beta"), + item("hidden", "Excluded"), + ], + properties: [], + } as unknown as ContentDatabaseResponse; + const markup = renderToStaticMarkup( + + + `Drag ${label}`, + moveUp: "Move up", + moveDown: "Move down", + moveTo: "Move to", + moveToPosition: (position) => `Position ${position}`, + }, + }} + labels={{ + noMatchesLabel: "No matches", + clearLabel: "Clear", + navigationLabel: "Files", + untitledLabel: "Untitled", + }} + /> + + , + ); + expect(markup.match(/aria-roledescription="sortable"/g)).toHaveLength(2); + expect(markup).toContain('data-sidebar-reorder-item-id="item-beta"'); + expect(markup).toContain('data-sidebar-reorder-item-id="item-alpha"'); + expect(markup.indexOf('href="/page/beta"')).toBeLessThan( + markup.indexOf('href="/page/alpha"'), + ); + expect(markup).not.toContain("Excluded"); + expect(markup).not.toContain("/page/hidden"); + }); + it("leaves the compact order control to the workspace header", () => { const markup = renderToStaticMarkup( diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index b63ef0044e5..667a88f6d16 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -1,11 +1,16 @@ +import { useActionQuery } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import type { ContentDatabaseItem, + ContentDatabaseNavigationItem, + ContentDatabaseNavigationPageResponse, + ContentDatabaseNavigationSort, ContentDatabaseOpenPagesIn, ContentDatabasePersonalViewOverrides, ContentDatabaseResponse, ContentDatabaseViewConfig, ContentSidebarViewOrder, + Document, } from "@shared/api"; import { IconChevronDown, @@ -20,9 +25,9 @@ import { IconTrash, } from "@tabler/icons-react"; import { useEffect, useState, type MouseEvent, type ReactNode } from "react"; -import { Link } from "react-router"; import { documentSidebarActionAvailability } from "@/components/sidebar/document-sidebar-actions"; +import { SidebarNavigationRow } from "@/components/sidebar/SidebarNavigationRow"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -77,6 +82,273 @@ export interface ContentFilesSidebarRenderReorder { labels: SidebarReorderLabels; } +export function navigationItemAsDatabaseItem( + item: ContentDatabaseNavigationItem, + document: Document | undefined, +): ContentDatabaseItem { + return { + id: item.membershipId, + databaseId: "", + position: item.membershipPosition, + properties: [], + document: document ?? { + id: item.documentId, + spaceId: item.spaceId, + parentId: item.parentId, + title: item.title, + content: "", + icon: item.icon, + position: item.membershipPosition, + isFavorite: item.isFavorite, + hideFromSearch: false, + canEdit: item.canEdit, + canManage: item.canManage, + source: item.sourceKind + ? { mode: "database", kind: item.sourceKind } + : undefined, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }, + }; +} + +export function PagedContentFilesSidebarView({ + databaseId, + sort, + viewId, + activeDocumentId, + expandedDocumentIds, + onDocumentExpandedChange, + documentMetadata, + activePathDocuments = [], + onOpenItem, + onCreateChildPage, + onCreateChildDatabase, + onDeleteItem, + onToggleFavorite, + navigationLabel, + untitledLabel, +}: { + databaseId: string; + sort: ContentDatabaseNavigationSort; + viewId?: string; + activeDocumentId?: string | null; + expandedDocumentIds: ReadonlySet; + onDocumentExpandedChange: (documentId: string, expanded: boolean) => void; + documentMetadata: ReadonlyMap; + activePathDocuments?: readonly Document[]; + onOpenItem?: (item: ContentDatabaseItem) => boolean; + onCreateChildPage?: (item: ContentDatabaseItem) => void; + onCreateChildDatabase?: (item: ContentDatabaseItem) => void; + onDeleteItem?: (item: ContentDatabaseItem) => void; + onToggleFavorite?: (item: ContentDatabaseItem) => void; + navigationLabel: string; + untitledLabel: string; +}) { + return ( + + ); +} + +function PagedContentFilesBranch({ + cursor, + precedingDocumentIds = new Set(), + ...props +}: { + databaseId: string; + parentId: string | null; + cursor?: string; + precedingDocumentIds?: ReadonlySet; + sort: ContentDatabaseNavigationSort; + viewId?: string; + depth: number; + activeDocumentId?: string | null; + expandedDocumentIds: ReadonlySet; + onDocumentExpandedChange: (documentId: string, expanded: boolean) => void; + documentMetadata: ReadonlyMap; + activePathDocuments: readonly Document[]; + onOpenItem?: (item: ContentDatabaseItem) => boolean; + onCreateChildPage?: (item: ContentDatabaseItem) => void; + onCreateChildDatabase?: (item: ContentDatabaseItem) => void; + onDeleteItem?: (item: ContentDatabaseItem) => void; + onToggleFavorite?: (item: ContentDatabaseItem) => void; + untitledLabel: string; +}) { + const t = useT(); + const [nextPageVisible, setNextPageVisible] = useState(false); + const query = useActionQuery("query-content-database-items", { + databaseId: props.databaseId, + limit: 20, + navigation: { + parentId: props.parentId, + sort: props.sort, + viewId: props.viewId, + cursor, + }, + }); + const data = + query.data && !("available" in query.data) + ? (query.data as ContentDatabaseNavigationPageResponse) + : undefined; + + if (query.isLoading) { + return ( + + ); + } + if (query.isError || !data) { + return ( +
+ +
+ ); + } + + const pageItems = data.items.filter( + (item) => !precedingDocumentIds.has(item.documentId), + ); + const pageDocumentIds = new Set([ + ...precedingDocumentIds, + ...pageItems.map((item) => item.documentId), + ]); + const revealedItems = props.activePathDocuments + .filter( + (document) => + document.parentId === props.parentId && + !pageDocumentIds.has(document.id), + ) + .map((document) => ({ + membershipId: `active-path-${document.id}`, + membershipPosition: document.position, + documentId: document.id, + parentId: document.parentId, + title: document.title, + icon: document.icon, + type: document.database ? ("database" as const) : ("page" as const), + hasChildren: props.activePathDocuments.some( + (candidate) => candidate.parentId === document.id, + ), + spaceId: null, + sourceKind: document.source?.kind ?? null, + isFavorite: document.isFavorite, + canEdit: document.canEdit !== false, + canManage: document.canManage === true, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + })); + const items = [...pageItems, ...revealedItems]; + const composedDocumentIds = new Set([ + ...pageDocumentIds, + ...revealedItems.map((item) => item.documentId), + ]); + + return ( + <> + {items.map((navigationItem) => { + const metadata = props.documentMetadata.get(navigationItem.documentId); + const item = navigationItemAsDatabaseItem(navigationItem, metadata); + const expanded = props.expandedDocumentIds.has( + navigationItem.documentId, + ); + return ( +
+ {}} + onOpenItem={props.onOpenItem} + active={navigationItem.documentId === props.activeDocumentId} + onCreateChildPage={props.onCreateChildPage} + onCreateChildDatabase={props.onCreateChildDatabase} + onDeleteItem={props.onDeleteItem} + onToggleFavorite={props.onToggleFavorite} + untitledLabel={props.untitledLabel} + depth={props.depth} + hasChildren={navigationItem.hasChildren} + expanded={expanded} + onToggleExpanded={(open) => + props.onDocumentExpandedChange(navigationItem.documentId, open) + } + /> + {expanded && navigationItem.hasChildren ? ( + + ) : null} +
+ ); + })} + {data.pagination.hasMore && data.pagination.nextCursor ? ( + nextPageVisible ? ( + + ) : ( + + ) + ) : null} + + ); +} + export function databaseSidebarReorderItems( items: ContentDatabaseItem[], untitledLabel: string, @@ -169,6 +441,7 @@ export function ContentFilesSidebarView({ labels, onSelectView, sidebarOrder, + serverOrdered = false, manualReorder, onOpenItem, onCreateChildPage, @@ -187,6 +460,7 @@ export function ContentFilesSidebarView({ onSelectView?: (viewId: string) => void; /** A parent-owned, user-scoped Files order. It never writes database membership. */ sidebarOrder?: ContentSidebarViewOrder; + serverOrdered?: boolean; manualReorder?: ContentFilesSidebarManualReorder; onOpenItem?: (item: ContentDatabaseItem) => boolean; onCreateChildPage?: (item: ContentDatabaseItem) => void; @@ -247,9 +521,10 @@ export function ContentFilesSidebarView({ activeView.filterMode ?? "and", ) : []; - const items = sidebarOrder - ? contentSidebarOrderedItems(filteredItems, sidebarOrder) - : filteredItems; + const items = + sidebarOrder && !serverOrdered + ? contentSidebarOrderedItems(filteredItems, sidebarOrder) + : filteredItems; const groups = databaseVisibleGroups( databaseViewItemGroups( items, @@ -269,8 +544,8 @@ export function ContentFilesSidebarView({ Boolean(manualReorder) && !items.some((item) => item.document.source?.kind === "folder") && (sidebarOrder?.mode ?? "custom") === "custom" && - activeView.sorts.length === 0 && - activeView.filters.length === 0 && + (Boolean(sidebarOrder) || + (activeView.sorts.length === 0 && activeView.filters.length === 0)) && !databaseViewGroupingProperty(activeView, usableData?.properties ?? []); return (
@@ -804,14 +1079,15 @@ function DatabaseSidebarRow({ /> ) : null} - event.currentTarget.blur()} aria-current={active ? "page" : undefined} > - {title} - + {(hasMenuActions || canCreateChild) && (
diff --git a/templates/content/app/components/layout/Layout.layout.test.ts b/templates/content/app/components/layout/Layout.layout.test.ts index 40c47eb6694..fee6a4a671e 100644 --- a/templates/content/app/components/layout/Layout.layout.test.ts +++ b/templates/content/app/components/layout/Layout.layout.test.ts @@ -8,6 +8,10 @@ function readLayoutSource() { }); } +function readRootSource() { + return readFileSync(new URL("../../root.tsx", import.meta.url), "utf8"); +} + describe("app layout", () => { it("exposes the sidebar width to editor content for responsive surfaces", () => { const source = readLayoutSource(); @@ -26,7 +30,9 @@ describe("app layout", () => { expect(source).toContain("const isCompactLayout = useIsCompactLayout()"); expect(source).toContain("{isCompactLayout ? ("); expect(source).toContain("}, [location.key])"); - expect(source).toContain('className="w-[85vw] max-w-80 p-0"'); + expect(source).toContain( + 'className="w-[85vw] max-w-80 border-sidebar-border bg-sidebar p-0 text-sidebar-foreground"', + ); expect(source).not.toContain("md:hidden"); }); @@ -64,6 +70,15 @@ describe("app layout", () => { expect(source).toContain("useCreatePage({ awaitPersist: false })"); }); + it("returns command-menu focus to the recorded visible launcher without a timer", () => { + const source = readRootSource(); + + expect(source).toContain("CONTENT_COMMAND_MENU_OPEN_EVENT"); + expect(source).toContain("commandTrigger.current ="); + expect(source).toContain("target.focus()"); + expect(source).not.toContain("setTimeout(() => target.focus"); + }); + it("includes the current document revision in chat history restores", () => { const source = readLayoutSource(); diff --git a/templates/content/app/components/layout/Layout.tsx b/templates/content/app/components/layout/Layout.tsx index 7d6b2acffdc..43a8f33d215 100644 --- a/templates/content/app/components/layout/Layout.tsx +++ b/templates/content/app/components/layout/Layout.tsx @@ -33,6 +33,7 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { useCreatePage } from "@/hooks/use-create-page"; import { useCreativeContextLab } from "@/hooks/use-creative-context-lab"; import { useOptimisticDocumentTitle } from "@/hooks/use-optimistic-document-title"; +import { openContentCommandMenu } from "@/lib/content-command-menu"; import { applyRegisteredDocumentHistoryRestore, prepareRegisteredDocumentHistoryRestore, @@ -176,6 +177,7 @@ export function Layout({ children }: LayoutProps) { }); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const sidebarTriggerRef = useRef(null); + const openSearchAfterSidebarCloseRef = useRef(false); const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); const handleSidebarResize = useCallback((width: number) => { @@ -247,8 +249,16 @@ export function Layout({ children }: LayoutProps) { { + if (openSearchAfterSidebarCloseRef.current) { + event.preventDefault(); + openSearchAfterSidebarCloseRef.current = false; + openContentCommandMenu( + sidebarTriggerRef.current ?? undefined, + ); + return; + } if (sidebarTriggerRef.current) { event.preventDefault(); sidebarTriggerRef.current.focus(); @@ -263,6 +273,10 @@ export function Layout({ children }: LayoutProps) { collapsed={false} onToggleCollapsed={() => setMobileSidebarOpen(false)} onNavigate={() => setMobileSidebarOpen(false)} + onOpenSearch={() => { + openSearchAfterSidebarCloseRef.current = true; + setMobileSidebarOpen(false); + }} /> diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 0c01a7691a9..748e9abc05f 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -28,14 +28,12 @@ describe("document sidebar layout", () => { collapsedBranchStart, ); - expect(sidebar).toContain("openCommandMenu"); + expect(sidebar).toContain("openContentCommandMenu"); expect(sidebar).toContain('t("sidebar.search")'); expect(sidebar.slice(collapsedBranchStart, expandedBranchStart)).toContain( - "renderSearchButton()", - ); - expect(sidebar.slice(expandedBranchStart)).toContain( - "renderSearchButton()", + "{collapsedSearchButton}", ); + expect(sidebar.slice(expandedBranchStart)).toContain("{searchButton}"); }); it("keeps deeply nested page rows within the sidebar viewport", () => { @@ -47,7 +45,7 @@ describe("document sidebar layout", () => { expect(sidebar).toContain( "[&_[data-radix-scroll-area-viewport]]:!overflow-x-hidden", ); - expect(sidebar).toContain('className="w-full min-w-0 py-2 pe-2"'); + expect(sidebar).toContain('className="w-full min-w-0 py-2"'); expect(sidebar).not.toContain("w-max"); expect(treeItem).toContain("const indent = depth * 12 + 12"); expect(treeItem).toContain("min-w-0"); @@ -137,6 +135,7 @@ describe("document sidebar layout", () => { expect(iconSource.indexOf("if (document.database)")).toBeLessThan( iconSource.indexOf('return "page"'), ); + expect(treeItem).toContain(""); }); it("uses the database icon as the default for database pages", () => { @@ -177,6 +176,19 @@ describe("document sidebar layout", () => { ); }); + it("keeps hosted sidebar reads bounded while isolating exhaustive local inventory", () => { + const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const documentsHook = readSidebarSource("../../hooks/use-documents.ts"); + + expect(sidebar).not.toContain("const documentsQuery = useDocuments();"); + expect(sidebar).toContain("useDocuments({ enabled: localFileMode })"); + expect(sidebar).toContain('"get-content-navigation-context"'); + expect(sidebar).toContain("limit: 50"); + expect(sidebar).not.toContain("limit: Math.max(contentSpaces.length, 1)"); + expect(sidebar).toContain("useContentSpaces()"); + expect(documentsHook).toContain("enabled: options?.enabled !== false"); + }); + it("does not keep a hidden sidebar search query field", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); @@ -184,6 +196,26 @@ describe("document sidebar layout", () => { expect(sidebar).not.toContain('placeholder={t("sidebar.search")}'); }); + it("keeps one command-menu search launcher above the navigation scroller", () => { + const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const layout = readSidebarSource("../layout/Layout.tsx"); + + expect(sidebar).toContain("openContentCommandMenu"); + expect(sidebar).toContain("openCommandMenuFrom(searchTriggerRef.current)"); + expect(sidebar).toContain('{isMac ? "⌘ K" : "Ctrl K"}'); + expect(sidebar.indexOf("{searchButton}")).toBeLessThan( + sidebar.indexOf(' { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); @@ -264,92 +296,34 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("navigate(previousPath, {"); }); - it("keeps independently expanded Files lists beneath their workspaces", () => { + it("renders one selected Content space with a shallow Files tree", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); - expect(sidebar).toContain("aria-expanded={expanded}"); expect(sidebar).toContain('"get-content-sidebar-state"'); expect(sidebar).toContain('"update-content-sidebar-state"'); expect(sidebar).toContain( - "stored?.expandedWorkspaceIds ?? contentSpaces.map", + "expandedDocumentIds={visibleExpandedDocumentIds}", ); - 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( + "new Set([...expandedDocumentIds, ...activeAncestorIds])", ); 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).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).toContain("{space.name}', + "renderWorkspaceRoot(selectedSpace, undefined, true)", ); - expect(sidebar).toContain('className="min-w-0 pb-1 ps-4"'); - expect(sidebar).not.toContain( - 'className="ms-3 border-s border-border/70 pb-1 ps-1"', - ); - expect(sidebar).toContain('role="link"'); + expect(sidebar).toContain("value={selectedSpace.id}"); + expect(sidebar).toContain("contentSpaces.map((space)"); + expect(sidebar).not.toContain(''); expect(sidebar).toContain( - 'aria-label={`${t("sidebar.newPage")} — ${space.name}`}', + "pinned: `/favorites?spaceId=${encodeURIComponent(selectedSpace.id)}`", ); - expect(sidebar).toContain("selected={selectedSpace?.id === space.id}"); - expect(sidebar).toContain("onOpenItem={(item: ContentDatabaseItem) =>"); expect(sidebar).toContain("void handleSelectContentSpace(space, null)"); - expect(sidebar).toContain( - "await handleCreatePage(undefined, space.id, id, space.filesDatabaseId)", - ); - expect(sidebar).toContain("activeDocumentId={activeDocumentId}"); - expect(sidebar).toContain("onCreateChildPage={(nextSpace, item) =>"); - expect(sidebar).toContain("onDeleteItem={(item) =>"); - expect(sidebar).toContain("onToggleFavorite={(item) =>"); - expect(sidebar).toContain( - "applyOptimisticItemToContentDatabase(current, optimisticItem)", - ); - expect(sidebar).not.toContain("', - ); expect(sidebar).toContain( 'import { OrgSwitcher } from "@agent-native/core/client/org";', ); @@ -360,12 +334,6 @@ describe("document sidebar layout", () => { expect(sidebar).toContain('t("sidebar.addWorkspace")'); expect(sidebar).toContain(""); - expect(sidebar).toContain("name: item.document.title || space.name"); expect(sidebar).toContain("scroll={false}"); }); @@ -390,6 +358,29 @@ describe("document sidebar layout", () => { expect(hooks).toContain( "isContentDatabaseByIdQueryEnabled(databaseId, options)", ); + expect(sidebar).toContain("const previouslyExpanded = useRef(false)"); + }); + + it("waits for a selected Content space before mounting scoped sidebar queries", () => { + const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const sections = readSidebarSource("./PersonalSidebarSections.tsx"); + + expect(sidebar).toContain("contentSpaceActionArgs(selectedSpace?.id)"); + expect(sidebar).toContain("enabled: Boolean(sidebarStateArgs)"); + expect(sidebar).toContain("{selectedSpace ? ("); + expect(sidebar).toContain("spaceId={selectedSpace.id}"); + expect(sidebar).not.toContain("key={selectedSpace.id}"); + expect(sidebar).toContain('t("sidebar.contentSpace")'); + expect(sections).toContain("contentSpaceActionArgs(spaceId)"); + expect(sections).toContain("optimisticBySpace.get(spaceId)"); + expect(sections).toContain("queueBySpace.current.get(targetSpaceId)"); + expect(sections).toContain("pendingBySpace.current.get(targetSpaceId)"); + expect(sidebar).toContain("contentSpaceActionArgs(snapshot.spaceId)"); + expect(sidebar).toContain("lastSyncedSpaceIdRef.current"); + expect(sidebar).toContain( + "workspaceSelectionQueueRef\n .current(async () =>", + ); + expect(sidebar).not.toContain("ensureWorkspaceExpanded(current, space.id)"); }); it("uses the full row width until right-side actions are revealed", () => { @@ -478,22 +469,49 @@ describe("document sidebar layout", () => { it("renders Pinned through exact database memberships with accessible reordering", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const sections = readSidebarSource("./PersonalSidebarSections.tsx"); + const reorder = readSidebarSource("./sidebar-reorder.tsx"); - expect(sidebar).toContain("{showFavorites && ("); - expect(sidebar).toContain('toggleSection("favorites")'); - expect(sidebar).toContain("!collapsedSections.favorites &&"); - expect(sidebar).toContain("aria-expanded={!collapsedSections.favorites}"); - expect(sidebar).toContain(""); + expect(sections).toContain("sections[id].visible"); + expect(sections).toContain("expanded={sections[id].expanded}"); + expect(sections).toContain( + "change(id, { expanded: !sections[id].expanded });", + ); + expect(sections).toContain("aria-expanded={expanded}"); + expect(sections).toContain('expanded && "rotate-90"'); + expect(sections).not.toContain("IconGripVertical"); + expect(sections).toContain("onPointerDown={pointerDragListener}"); + expect(sections).toContain("onClick={onToggle}"); + expect(sections).toContain("grid-cols-[minmax(0,1fr)_1.75rem]"); + expect(sections).toContain("grid-cols-[1.75rem_minmax(0,1fr)]"); + expect(sections).not.toContain("{...reorder.listeners}"); + expect(sections).toContain("data-sidebar-reorder-item-id={reorder.itemId}"); + expect(reorder).toContain("activationConstraint: { distance: 5 }"); + expect(sections).toContain("group-hover/toggle:opacity-0"); + expect(sections).toContain("group-focus-visible/toggle:opacity-100"); + expect(sections).toContain(" item.id)"); + expect(sidebar).toContain("contentSidebarSubsetReorder("); expect(sidebar).toContain( "flex h-7 w-full min-w-0 items-center rounded-md px-1", ); @@ -501,6 +519,57 @@ describe("document sidebar layout", () => { expect(sidebar).not.toContain("!localFileMode && favorites.length > 0"); }); + it("aligns the expanded sidebar controls to one trailing grid", () => { + const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const sections = readSidebarSource("./PersonalSidebarSections.tsx"); + + expect(sidebar).toContain( + "grid-cols-[minmax(0,1fr)_2rem] items-center gap-1 ps-3 pe-2", + ); + expect(sidebar).toContain("grid-cols-[1.75rem_minmax(0,1fr)_1.75rem]"); + expect(sidebar).toContain("w-[var(--radix-dropdown-menu-trigger-width)]"); + expect(sidebar).toContain("max-w-[calc(100vw-1rem)]"); + expect(sidebar).toContain('className="min-w-0 flex-1 truncate"'); + expect(sidebar).toContain('className="shrink-0 ps-3 pe-2 py-2"'); + expect(sections).toContain( + "size-7 text-muted-foreground hover:text-foreground focus-visible:text-foreground", + ); + }); + + it("keeps section visibility inside each section menu instead of a duplicate customize row", () => { + const sections = readSidebarSource("./PersonalSidebarSections.tsx"); + + // A standalone sidebar-level 3-dot row duplicated the section header menu + // and burned a whole row of vertical space. + expect(sections).not.toContain('className="flex justify-end px-3"'); + expect(sections).not.toContain(''); + + // The visibility toggles now live under Move up/Move down in every section + // menu, so a hidden section is always restorable. + expect(sections).toContain(""); + expect(sections).toContain("checked={sections[sectionId].visible}"); + expect(sections).toContain("onChangeVisible(sectionId, visible)"); + expect(sections).toContain("change(sectionId, { visible })"); + + // Both call sites, including the always-visible "workspaces" section, wire + // the visibility group. + expect( + sections.split("onChangeVisible={(sectionId, visible) =>").length - 1, + ).toBe(2); + + // The section menu trigger must not reuse the drag handle's label. + expect(sections).toContain('aria-label={t("sidebar.customizeSidebar")}'); + const menuTrigger = sections.slice( + sections.indexOf("{t("sidebar.seeAll")}', + ); + }); + it("keeps delete confirmation owned by the stable sidebar", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); const treeItem = readSidebarSource("./DocumentTreeItem.tsx"); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index a6ab9340c33..f9588c472ea 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -7,7 +7,6 @@ import { useActionQuery, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; -import { openCommandMenu } from "@agent-native/core/client/navigation"; import { OrgSwitcher } from "@agent-native/core/client/org"; import { AppSidebarFooter, @@ -19,6 +18,7 @@ import type { ContentDatabasePersonalViewOverrides, ContentDatabaseResponse, ContentSidebarViewOrder, + ContentNavigationContext, Document, } from "@shared/api"; import { CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION } from "@shared/api"; @@ -29,7 +29,6 @@ import { IconPlus, IconRestore, IconSettings, - IconPin, IconTrashX, IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, @@ -46,6 +45,8 @@ import { toast } from "sonner"; import { ContentFilesSidebarView, + PagedContentFilesSidebarView, + contentSidebarOrderedItems, type ContentFilesSidebarRenderReorder, } from "@/components/editor/database/sidebar"; import { QueryErrorState } from "@/components/QueryErrorState"; @@ -60,6 +61,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -84,13 +86,13 @@ import { removeOptimisticItemFromContentDatabase, useContentDatabaseById, useContentDatabasePersonalView, - useMoveDatabaseItem, useUpdateContentDatabasePersonalView, useCreateContentDatabase, useDeleteContentDatabase, useRestoreContentDatabase, useTrashedContentDatabases, } from "@/hooks/use-content-database"; +import { useUpdateContentPersonalNavigation } from "@/hooks/use-content-personal-navigation"; import { shouldAutoEnsureContentSpaces, useContentSpaces, @@ -105,12 +107,12 @@ import { useRestoreDocument, useTrashedDocuments, useUpdateDocument, - filterDocumentTreeDocuments, documentQueryFilter, rollbackOptimisticCreatedDocument, restoreDeletedDocumentSnapshots, } from "@/hooks/use-documents"; import { useLocalStorage } from "@/hooks/use-local-storage"; +import { openContentCommandMenu } from "@/lib/content-command-menu"; import { getDesktopContentFiles, type DesktopContentFilesFolder, @@ -127,18 +129,20 @@ import { } from "@/lib/optimistic-document"; import { cn } from "@/lib/utils"; -import { getDocumentSidebarSections } from "./document-sidebar-sections"; import { firstLocalSourceDocumentId, localSourceItemIdentity, projectLocalSourceHierarchy, } from "./local-source-hierarchy"; +import { PersonalSidebarSections } from "./PersonalSidebarSections"; import { + contentSpaceActionArgs, contentSpaceAvailability, + contentSpaceRouteReconciliation, + contentSidebarSubsetReorder, contentSpaceForStoredSelection, createContentSidebarStateWriteQueue, createContentSpaceSelectionQueue, - ensureWorkspaceExpanded, SELECTED_CONTENT_SPACE_STORAGE_KEY, selectContentSpace, toggleExpandedWorkspaceIds, @@ -161,12 +165,17 @@ interface DocumentSidebarProps { collapsed: boolean; onToggleCollapsed: () => void; onNavigate?: () => void; + onOpenSearch?: () => void; width?: number; onResize?: (width: number) => void; minWidth?: number; maxWidth?: number; } +function openCommandMenuFrom(trigger: HTMLButtonElement | null) { + openContentCommandMenu(trigger ?? undefined); +} + const LIST_DOCUMENTS_QUERY_KEY = [ "action", "list-documents", @@ -217,12 +226,13 @@ 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; +const CONTENT_SIDEBAR_STATE_VERSION = 2 as const; interface ContentSidebarStateSnapshot { version: typeof CONTENT_SIDEBAR_STATE_VERSION; - expandedWorkspaceIds: string[]; - expandedDocumentIds: string[]; + spaceId?: string; + expandedWorkspaceIds?: string[]; + expandedDocumentIds?: string[]; } const DEFAULT_COLLAPSED_SECTIONS: CollapsedSectionsState = { favorites: false, @@ -315,7 +325,7 @@ function useDeferredFilesDatabaseId( expanded: boolean, deferUntilDocumentId: string | null, ) { - const previouslyExpanded = useRef(expanded); + const previouslyExpanded = useRef(false); const [ready, setReady] = useState(false); useEffect(() => { @@ -384,11 +394,14 @@ function WorkspaceSidebarItem({ space, selected, expanded, + localFileMode, deferInitialReadUntilDocumentId, reorder, createDocumentPending, activeDocumentId, expandedDocumentIds, + documentMetadata, + activePathDocuments, onDocumentExpandedChange, onActivate, onToggleExpanded, @@ -397,15 +410,19 @@ function WorkspaceSidebarItem({ onCreateChildDatabase, onDeleteItem, onToggleFavorite, + compact = false, }: { space: ContentSpaceSummary; selected: boolean; expanded: boolean; + localFileMode: boolean; deferInitialReadUntilDocumentId: string | null; reorder?: ContentFilesSidebarRenderReorder; createDocumentPending: boolean; activeDocumentId: string | null; expandedDocumentIds: ReadonlySet; + documentMetadata: ReadonlyMap; + activePathDocuments: readonly Document[]; onDocumentExpandedChange: (documentId: string, expanded: boolean) => void; onActivate: (space: ContentSpaceSummary, documentId?: string) => void; onToggleExpanded: () => void; @@ -420,6 +437,7 @@ function WorkspaceSidebarItem({ ) => void; onDeleteItem: (item: ContentDatabaseItem) => void; onToggleFavorite: (item: ContentDatabaseItem) => void; + compact?: boolean; }) { const t = useT(); const [localWorkingCopies, setLocalWorkingCopies] = useState< @@ -568,7 +586,9 @@ function WorkspaceSidebarItem({ openedWorkingCopyIdRef.current = selectedWorkingCopy.id; onActivate(space, firstDocumentId); }, [onActivate, selectedWorkingCopy, space, visibleFilesDatabaseData]); - const resolvedFilesDatabaseId = filesDatabaseData?.database.id ?? null; + const resolvedFilesDatabaseId = localFileMode + ? (filesDatabaseData?.database.id ?? null) + : space.filesDatabaseId; const filesPersonalView = useContentDatabasePersonalView( resolvedFilesDatabaseId, ); @@ -576,10 +596,15 @@ function WorkspaceSidebarItem({ resolvedFilesDatabaseId, ); const failed = filesDatabase.isError || filesPersonalView.isError; - const { activeViewId, order: sidebarOrder } = personalSidebarOrderForDatabase( - filesDatabaseData, - filesPersonalView.data?.overrides, - ); + const pagedOverrides = filesPersonalView.data?.overrides; + const { activeViewId, order: sidebarOrder } = localFileMode + ? personalSidebarOrderForDatabase(filesDatabaseData, pagedOverrides) + : { + activeViewId: pagedOverrides?.activeViewId ?? "default", + order: pagedOverrides?.views.find( + (view) => view.id === (pagedOverrides.activeViewId ?? "default"), + )?.sidebarOrder ?? { mode: "custom" as const, itemIds: [] }, + }; const reorderLabels: SidebarReorderLabels = { drag: (label) => t("sidebar.dragToReorder", { label }), moveUp: t("sidebar.moveUp"), @@ -618,119 +643,125 @@ function WorkspaceSidebarItem({ return (
-
- - { - if ( - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - !event.altKey - ) { - event.preventDefault(); - onActivate(space); - } - }} - > - {space.name} - - {expanded ? ( - - - - - - - - - {t("sidebar.orderButton", { - order: sidebarOrderModeLabels[sidebarOrder.mode], - })} - - - - - updateSidebarOrder({ - ...sidebarOrder, - mode: value as ContentSidebarViewOrder["mode"], - }) - } - > - {( - Object.keys( - sidebarOrderModeLabels, - ) as ContentSidebarViewOrder["mode"][] - ).map((mode) => ( - - {sidebarOrderModeLabels[mode]} - - ))} - - - - ) : null} - -
+ + { + if ( + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + onActivate(space); + } + }} + > + {space.name} + + {expanded ? ( + + + + + + + + + {t("sidebar.orderButton", { + order: sidebarOrderModeLabels[sidebarOrder.mode], + })} + + + + + updateSidebarOrder({ + ...sidebarOrder, + mode: value as ContentSidebarViewOrder["mode"], + }) + } + > + {( + Object.keys( + sidebarOrderModeLabels, + ) as ContentSidebarViewOrder["mode"][] + ).map((mode) => ( + + {sidebarOrderModeLabels[mode]} + + ))} + + + + ) : null} + +
+ )} {expanded ? ( -
+
{relatedWorkingCopies.some((folder) => folder.kind === "temporary") && selectedWorkingCopy && hasRelatedLocalFiles ? ( @@ -798,7 +829,7 @@ function WorkspaceSidebarItem({ filesDatabase.isFetching || filesPersonalView.isFetching } /> - ) : ( + ) : localFileMode ? ( + ) : ( + document.id !== space.filesDocumentId, + )} + onOpenItem={(item) => { + if (selected) return false; + onActivate(space, item.document.id); + return true; + }} + onCreateChildPage={(item) => onCreateChildPage(space, item)} + onCreateChildDatabase={(item) => + onCreateChildDatabase(space, item) + } + onDeleteItem={onDeleteItem} + onToggleFavorite={onToggleFavorite} + navigationLabel={`${space.name} ${t("sidebar.files")}`} + untitledLabel={t("sidebar.untitled")} + /> )}
) : null} @@ -859,6 +917,7 @@ export function DocumentSidebar({ collapsed, onToggleCollapsed, onNavigate, + onOpenSearch, width, onResize, minWidth, @@ -868,6 +927,14 @@ export function DocumentSidebar({ const location = useLocation(); const queryClient = useQueryClient(); const t = useT(); + const searchTriggerRef = useRef(null); + const handleOpenSearch = useCallback(() => { + if (onOpenSearch) { + onOpenSearch(); + return; + } + openCommandMenuFrom(searchTriggerRef.current); + }, [onOpenSearch]); const sidebarReorderLabels = useMemo( () => ({ drag: (label) => t("sidebar.dragToReorder", { label }), @@ -878,7 +945,9 @@ export function DocumentSidebar({ }), [t], ); - const documentsQuery = useDocuments(); + const contentSpacesQuery = useContentSpaces(); + const localFileMode = contentSpacesQuery.data?.sourceMode === "local-files"; + const documentsQuery = useDocuments({ enabled: localFileMode }); const { data: documents = [] } = documentsQuery; const createDocument = useCreateDocument(); const createDatabase = useCreateContentDatabase(null, { @@ -894,37 +963,38 @@ export function DocumentSidebar({ const { data: trashedDatabases } = useTrashedContentDatabases(); const { isCodeMode } = useCodeMode(); const updateDocument = useUpdateDocument(); - const contentSpacesQuery = useContentSpaces(); const ensureContentSpaces = useEnsureContentSpaces(); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); + const lastSyncedSpaceIdRef = useRef(null); + const explicitSpaceSelectionRef = useRef(null); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; - const workspaceCatalogDatabaseId = - contentSpacesQuery.data?.catalogDatabaseId ?? null; - const workspaceCatalogDocumentId = - contentSpacesQuery.data?.catalogDocumentId ?? null; + const [storedSpaceId, setStoredSpaceId] = useLocalStorage( + SELECTED_CONTENT_SPACE_STORAGE_KEY, + null, + { syncAcrossTabs: false }, + ); + const selectedSpace = contentSpaceForStoredSelection({ + spaces: contentSpaces, + storedSpaceId, + }); const favoritesDatabaseId = contentSpacesQuery.data?.favoritesDatabaseId ?? null; - const favoritesDocumentId = - contentSpacesQuery.data?.favoritesDocumentId ?? null; - const favoritesDatabase = useContentDatabaseById(favoritesDatabaseId); - const workspaceCatalogDatabase = useContentDatabaseById( - workspaceCatalogDatabaseId, - ); - const workspaceCatalogDatabaseData = isContentDatabaseUnavailable( - workspaceCatalogDatabase.data, - ) + const favoritesDatabase = useContentDatabaseById(favoritesDatabaseId, { + limit: 50, + contentSpaceId: selectedSpace?.id, + }); + const favoritesPersonalView = + useContentDatabasePersonalView(favoritesDatabaseId); + const favoritesData = isContentDatabaseUnavailable(favoritesDatabase.data) ? undefined - : workspaceCatalogDatabase.data; - const resolvedWorkspaceCatalogDatabaseId = - workspaceCatalogDatabaseData?.database.id ?? null; - const workspaceCatalogPersonalView = useContentDatabasePersonalView( - resolvedWorkspaceCatalogDatabaseId, + : favoritesDatabase.data; + const updateFavoritesPersonalView = useUpdateContentPersonalNavigation( + favoritesDatabaseId, + favoritesData?.database.viewConfig.views, ); - const updateWorkspaceCatalogPersonalView = - useUpdateContentDatabasePersonalView(resolvedWorkspaceCatalogDatabaseId); - const movePinnedItem = useMoveDatabaseItem(favoritesDocumentId ?? ""); - const moveWorkspaceItem = useMoveDatabaseItem( - workspaceCatalogDocumentId ?? "", + const favoritesOrder = personalSidebarOrderForDatabase( + favoritesData, + favoritesPersonalView.data?.overrides, ); const attemptedSpaceReconciliationKeyRef = useRef(null); const spaceReconciliationRetryTimerRef = useRef( - SELECTED_CONTENT_SPACE_STORAGE_KEY, - null, + const sidebarStateArgs = contentSpaceActionArgs(selectedSpace?.id); + const sidebarStateQuery = useActionQuery( + "get-content-sidebar-state", + sidebarStateArgs, + { enabled: Boolean(sidebarStateArgs) }, ); - const selectedSpace = contentSpaceForStoredSelection({ - spaces: contentSpaces, - storedSpaceId, - }); - const sidebarStateQuery = useActionQuery("get-content-sidebar-state", {}); - const updateSidebarState = useActionMutation("update-content-sidebar-state", { + const updateSidebarState = useActionMutation< + unknown, + ContentSidebarStateSnapshot + >("update-content-sidebar-state", { skipActionQueryInvalidation: true, - onSuccess: (data) => { + onSuccess: (data, snapshot) => { queryClient.setQueryData( - ["action", "get-content-sidebar-state", {}], + [ + "action", + "get-content-sidebar-state", + contentSpaceActionArgs(snapshot.spaceId), + ], data, ); }, @@ -1012,10 +1086,6 @@ export function DocumentSidebar({ [], ); const [expandedDocumentIds, setExpandedDocumentIds] = useState([]); - const expandedDocumentIdSet = useMemo( - () => new Set(expandedDocumentIds), - [expandedDocumentIds], - ); const sidebarStateHydratedRef = useRef(false); const expandedWorkspaceIdsRef = useRef([]); const expandedDocumentIdsRef = useRef([]); @@ -1039,10 +1109,12 @@ export function DocumentSidebar({ ensureContentSpaces.mutate({}); }, [contentSpacesQuery, ensureContentSpaces]); useEffect(() => { - if (selectedSpace && selectedSpace.id !== storedSpaceId) { - setStoredSpaceId(selectedSpace.id); - } - }, [selectedSpace, setStoredSpaceId, storedSpaceId]); + sidebarStateHydratedRef.current = false; + expandedWorkspaceIdsRef.current = []; + expandedDocumentIdsRef.current = []; + setExpandedWorkspaceIds([]); + setExpandedDocumentIds([]); + }, [selectedSpace?.id]); useEffect(() => { if ( sidebarStateHydratedRef.current || @@ -1073,6 +1145,7 @@ export function DocumentSidebar({ void sidebarStateWriteQueueRef .current?.({ version: CONTENT_SIDEBAR_STATE_VERSION, + spaceId: selectedSpace?.id, expandedWorkspaceIds: workspaceIds, expandedDocumentIds: documentIds, }) @@ -1082,7 +1155,7 @@ export function DocumentSidebar({ }); }); }, - [t], + [selectedSpace?.id, t], ); const updateExpandedWorkspaceIds = useCallback( @@ -1160,15 +1233,15 @@ export function DocumentSidebar({ space: (typeof contentSpaces)[number], targetDocumentId?: string | null, ) => { - updateExpandedWorkspaceIds((current) => - ensureWorkspaceExpanded(current, space.id), - ); + if (targetDocumentId === undefined) { + explicitSpaceSelectionRef.current = space.id; + } try { await workspaceSelectionQueueRef.current(() => selectContentSpace({ space, - syncApplicationState: (selected) => - setClientAppState( + syncApplicationState: async (selected) => { + await setClientAppState( "content-space", { spaceId: selected.id, @@ -1177,13 +1250,20 @@ export function DocumentSidebar({ filesDatabaseId: selected.filesDatabaseId, }, { requestSource: "content-sidebar" }, - ), + ); + lastSyncedSpaceIdRef.current = selected.id; + }, persistSelection: setStoredSpaceId, - openFiles: (documentId) => { + openSpace: (spaceId) => { if (targetDocumentId === null) return; - void navigate(`/page/${targetDocumentId ?? documentId}`, { - flushSync: true, - }); + void navigate( + targetDocumentId + ? `/page/${targetDocumentId}` + : `/home?spaceId=${encodeURIComponent(spaceId)}`, + { + flushSync: true, + }, + ); }, }), ); @@ -1193,7 +1273,7 @@ export function DocumentSidebar({ return false; } }, - [navigate, setStoredSpaceId, updateExpandedWorkspaceIds], + [navigate, setStoredSpaceId], ); const handleWorkspaceCreated = useCallback( (created: CreatedWorkspace) => @@ -1212,24 +1292,35 @@ export function DocumentSidebar({ [handleSelectContentSpace], ); useEffect(() => { - if (!selectedSpace) return; - void setClientAppState( - "content-space", - { - spaceId: selectedSpace.id, - name: selectedSpace.name, - kind: selectedSpace.kind, - filesDatabaseId: selectedSpace.filesDatabaseId, - }, - { requestSource: "content-sidebar" }, - ).catch(() => { - // Space selection remains usable when best-effort agent context sync fails. - }); + if (!selectedSpace || lastSyncedSpaceIdRef.current === selectedSpace.id) + return; + void workspaceSelectionQueueRef + .current(async () => { + if (lastSyncedSpaceIdRef.current === selectedSpace.id) return; + await setClientAppState( + "content-space", + { + spaceId: selectedSpace.id, + name: selectedSpace.name, + kind: selectedSpace.kind, + filesDatabaseId: selectedSpace.filesDatabaseId, + }, + { requestSource: "content-sidebar" }, + ); + lastSyncedSpaceIdRef.current = selectedSpace.id; + }) + .catch(() => { + // Space selection remains usable when best-effort agent context sync fails. + }); }, [selectedSpace]); const removeLocalFileSource = useActionMutation< RemoveLocalFileSourceResult, { sourceRootPath?: string | null } >("remove-local-file-source"); + const [isMac, setIsMac] = useState(false); + useEffect(() => { + setIsMac(/Mac|iPhone|iPad|iPod/.test(navigator.platform)); + }, []); // Track user-expanded nodes only; active ancestors are derived below so they // do not stay open after navigation unless the user explicitly expanded them. const expandedIdsRef = useRef(new Set()); @@ -1267,13 +1358,13 @@ export function DocumentSidebar({ const settleOptimisticListRefresh = useCallback( (id: string) => { pendingOptimisticCreationIdsRef.current.delete(id); - if (pendingOptimisticCreationIdsRef.current.size === 0) { + if (localFileMode && pendingOptimisticCreationIdsRef.current.size === 0) { void queryClient.invalidateQueries({ queryKey: LIST_DOCUMENTS_QUERY_KEY, }); } }, - [queryClient], + [localFileMode, queryClient], ); const settingsActive = location.pathname.startsWith("/settings"); @@ -1306,31 +1397,95 @@ export function DocumentSidebar({ [onResize, width], ); - const treeDocuments = filterDocumentTreeDocuments(documents); - const { localFileMode, databaseDocuments, showFavorites } = - getDocumentSidebarSections(documents, treeDocuments); - - const activeDocument = activeDocumentId - ? documents.find((doc) => doc.id === activeDocumentId) - : null; + const navigationContextQuery = useActionQuery( + "get-content-navigation-context", + activeDocumentId ? { id: activeDocumentId } : undefined, + { enabled: Boolean(activeDocumentId) }, + ); + useEffect(() => { + const filesDatabaseId = + navigationContextQuery.data?.workspaceFilesDatabaseId ?? null; + const reconciliation = contentSpaceRouteReconciliation({ + activeDocumentId, + routeDocumentId: navigationContextQuery.data?.document.id, + routeFilesDatabaseId: filesDatabaseId, + selectedSpace, + explicitSpaceId: explicitSpaceSelectionRef.current, + spaces: contentSpaces, + }); + if (reconciliation.explicitSelectionReachedRoute) { + explicitSpaceSelectionRef.current = null; + } + if (reconciliation.routeSpace) { + void handleSelectContentSpace(reconciliation.routeSpace, null); + } + }, [ + activeDocumentId, + contentSpaces, + handleSelectContentSpace, + navigationContextQuery.data, + selectedSpace?.filesDatabaseId, + ]); + const activeDocument = navigationContextQuery.data?.document ?? null; const trashItems = trashedDatabases?.databases ?? []; const trashedPageItems = trashedDocuments?.documents ?? []; - const parentByDocumentId = useMemo( - () => new Map(documents.map((doc) => [doc.id, doc.parentId])), - [documents], + const activePathDocuments = useMemo( + () => + localFileMode + ? documents.filter((document) => + navigationContextQuery.data?.path.some( + (entry) => entry.id === document.id, + ), + ) + : (navigationContextQuery.data?.path.map( + (entry): Document => ({ + id: entry.id, + parentId: entry.parentId, + title: entry.title, + content: "", + description: "", + icon: entry.icon, + position: 0, + isFavorite: entry.isFavorite, + hideFromSearch: false, + visibility: entry.visibility, + accessRole: entry.accessRole, + canView: entry.canView, + canComment: entry.canComment, + canEdit: entry.canEdit, + canManage: entry.canManage, + source: entry.source, + database: undefined, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + databaseMembership: entry.databaseId + ? { + databaseId: entry.databaseId, + databaseDocumentId: entry.databaseDocumentId, + databaseTitle: null, + position: null, + } + : undefined, + }), + ) ?? []), + [documents, localFileMode, navigationContextQuery.data?.path], + ); + const documentMetadata = useMemo( + () => new Map(activePathDocuments.map((doc) => [doc.id, doc])), + [activePathDocuments], ); const activeAncestorIds = useMemo(() => { const ids = new Set(); - let parentId = activeDocumentId - ? (parentByDocumentId.get(activeDocumentId) ?? null) - : null; - while (parentId && !ids.has(parentId)) { - ids.add(parentId); - parentId = parentByDocumentId.get(parentId) ?? null; + for (const entry of navigationContextQuery.data?.path ?? []) { + if (entry.id !== activeDocumentId) ids.add(entry.id); } return ids; - }, [activeDocumentId, parentByDocumentId]); + }, [activeDocumentId, navigationContextQuery.data?.path]); + const visibleExpandedDocumentIds = useMemo( + () => new Set([...expandedDocumentIds, ...activeAncestorIds]), + [activeAncestorIds, expandedDocumentIds], + ); const expandedIds = new Set(expandedIdsRef.current); for (const id of activeAncestorIds) expandedIds.add(id); @@ -1400,9 +1555,9 @@ export function DocumentSidebar({ createdAt: now, updatedAt: now, }); - const previousDocuments = queryClient.getQueryData( - LIST_DOCUMENTS_QUERY_KEY, - ); + const previousDocuments = localFileMode + ? queryClient.getQueryData(LIST_DOCUMENTS_QUERY_KEY) + : undefined; const previousPath = `${location.pathname}${location.search}${location.hash}`; pendingOptimisticCreationIdsRef.current.add(id); @@ -1523,17 +1678,19 @@ export function DocumentSidebar({ createdAt: now, updatedAt: now, }); - const previousDocuments = queryClient.getQueryData( - LIST_DOCUMENTS_QUERY_KEY, - ); + const previousDocuments = localFileMode + ? queryClient.getQueryData(LIST_DOCUMENTS_QUERY_KEY) + : undefined; const previousPath = `${location.pathname}${location.search}${location.hash}`; pendingOptimisticCreationIdsRef.current.add(id); - queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: any) => { - const docs: Document[] = - old?.documents ?? (Array.isArray(old) ? old : documents); - return withDocumentsCacheShape(old, [...docs, tempDoc]); - }); + if (localFileMode) { + queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: any) => { + const docs: Document[] = + old?.documents ?? (Array.isArray(old) ? old : documents); + return withDocumentsCacheShape(old, [...docs, tempDoc]); + }); + } queryClient.setQueryData(["action", "get-document", { id }], tempDoc); navigateToDocument(id); onNavigate?.(); @@ -1566,11 +1723,13 @@ export function DocumentSidebar({ settleParentExpansion(true); } catch (err) { settleParentExpansion(false); - rollbackOptimisticCreatedDocument( - queryClient, - id, - previousDocuments !== undefined, - ); + if (localFileMode) { + rollbackOptimisticCreatedDocument( + queryClient, + id, + previousDocuments !== undefined, + ); + } queryClient.removeQueries(documentQueryFilter(id)); settleOptimisticListRefresh(id); if (window.location.pathname === `/page/${id}`) { @@ -1616,9 +1775,11 @@ export function DocumentSidebar({ const handleDelete = useCallback( async (id: string) => { - const deletedDocument = documents.find((doc) => doc.id === id) ?? null; - const deletedIds = collectDocumentSubtreeIds(documents, id); - const activeDeleted = activeDocumentId + const deletedDocument = documentMetadata.get(id) ?? null; + const deletedIds = localFileMode + ? collectDocumentSubtreeIds(documents, id) + : new Set([id]); + let activeDeleted = activeDocumentId ? deletedIds.has(activeDocumentId) : false; const survivingDocuments = documents.filter( @@ -1639,15 +1800,17 @@ export function DocumentSidebar({ ); const previousPath = `${location.pathname}${location.search}${location.hash}`; - queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => { - const cachedDocs: Document[] = - (old as { documents?: Document[] })?.documents ?? - (Array.isArray(old) ? old : documents); - return withDocumentsCacheShape( - old, - cachedDocs.filter((doc) => !deletedIds.has(doc.id)), - ); - }); + if (localFileMode) { + queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => { + const cachedDocs: Document[] = + (old as { documents?: Document[] })?.documents ?? + (Array.isArray(old) ? old : documents); + return withDocumentsCacheShape( + old, + cachedDocs.filter((doc) => !deletedIds.has(doc.id)), + ); + }); + } for (const deletedId of deletedIds) { queryClient.removeQueries(documentQueryFilter(deletedId)); } @@ -1661,22 +1824,42 @@ export function DocumentSidebar({ try { if (deletedDocument?.database) { - await deleteContentDatabase.mutateAsync({ + const result = await deleteContentDatabase.mutateAsync({ databaseId: deletedDocument.database.id, + activeDocumentId: activeDocumentId ?? undefined, }); + activeDeleted = result.activeTargetDeleted; + if (result.navigationPath) { + void navigate(result.navigationPath, { + replace: true, + flushSync: true, + }); + } } else { - await deleteDocument.mutateAsync({ id }); + const result = await deleteDocument.mutateAsync({ + id, + activeDocumentId: activeDocumentId ?? undefined, + }); + activeDeleted = result.activeTargetDeleted ?? false; + if (result.navigationPath) { + void navigate(result.navigationPath, { + replace: true, + flushSync: true, + }); + } } void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); } catch (err) { - restoreDeletedDocumentSnapshots( - queryClient, - previousDocuments, - previousDocumentQueries, - deletedIds, - ); + if (localFileMode) { + restoreDeletedDocumentSnapshots( + queryClient, + previousDocuments, + previousDocumentQueries, + deletedIds, + ); + } void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); @@ -1713,53 +1896,29 @@ export function DocumentSidebar({ }, []); const handlePinnedReorder = useCallback( - (_itemIds: string[], moved: { itemId: string; position: number }) => { + (itemIds: string[], previousItemIds: string[]) => { if (!favoritesDatabaseId) return; - movePinnedItem.mutate( + updateFavoritesPersonalView.mutate( { databaseId: favoritesDatabaseId, - itemId: moved.itemId, - position: moved.position, - }, - { - onError: (error) => { - toast.error(t("sidebar.failedSaveOrder"), { - description: - error instanceof Error - ? error.message - : t("empty.genericError"), - }); - }, - }, - ); - }, - [favoritesDatabaseId, movePinnedItem, t], - ); - - const handleWorkspaceReorder = useCallback( - (_itemIds: string[], moved: { itemId: string; position: number }) => { - if (!workspaceCatalogDatabaseId) return; - moveWorkspaceItem.mutate( - { - databaseId: workspaceCatalogDatabaseId, - itemId: moved.itemId, - position: moved.position, - }, - { - onError: (error) => { - toast.error(t("sidebar.failedSaveOrder"), { - description: - error instanceof Error - ? error.message - : t("empty.genericError"), - }); + navigation: { + sidebarOrder: { + viewId: favoritesOrder.activeViewId, + ...contentSidebarSubsetReorder(itemIds, previousItemIds), + }, }, }, + { onError: () => toast.error(t("sidebar.failedSaveOrder")) }, ); }, - [moveWorkspaceItem, t, workspaceCatalogDatabaseId], + [ + favoritesDatabaseId, + favoritesOrder, + favoritesPersonalView.data?.overrides, + updateFavoritesPersonalView, + t, + ], ); - const handleToggleFavorite = useCallback( (id: string, isFavorite: boolean) => { updateDocument.mutate( @@ -1883,32 +2042,6 @@ export function DocumentSidebar({ ) : null; - const renderSearchButton = () => - collapsed ? ( - - - - - {t("sidebar.search")} - - ) : ( - - ); - const renderSettingsNavButton = () => ( ); + const collapsedSearchButton = ( + + + + + {t("sidebar.search")} + + ); + const searchButton = ( + + ); + const contentSpaceSelector = selectedSpace ? ( +
+ + + + + + { + const space = contentSpaces.find( + (candidate) => candidate.id === spaceId, + ); + if (space) void handleSelectContentSpace(space); + }} + > + {contentSpaces.map((space) => ( + + + + + {space.name} + + + + {space.name} + + ))} + + + + + + +
+ ) : null; const feedbackButton = ( ); @@ -1977,21 +2200,34 @@ export function DocumentSidebar({ const renderWorkspaceRoot = ( space: ContentSpaceSummary, reorder?: ContentFilesSidebarRenderReorder, + compact = false, ) => ( document.id === activeDocumentId) + navigationContextQuery.data?.workspaceFilesDatabaseId === + space.filesDatabaseId ? activeDocumentId : null } reorder={reorder} createDocumentPending={createDocument.isPending} activeDocumentId={activeDocumentId} - expandedDocumentIds={expandedDocumentIdSet} + expandedDocumentIds={visibleExpandedDocumentIds} + documentMetadata={documentMetadata} + activePathDocuments={ + activePathDocuments.some( + (document) => + document.databaseMembership?.databaseId === space.filesDatabaseId, + ) + ? activePathDocuments + : [] + } onDocumentExpandedChange={handleDocumentExpandedChange} onToggleExpanded={() => updateExpandedWorkspaceIds((current) => @@ -2028,92 +2264,10 @@ export function DocumentSidebar({ ); const renderWorkspaceNavigation = () => ( -
+
{contentSpaceState === "ready" && selectedSpace ? (
- {workspaceCatalogDatabase.isError || - workspaceCatalogPersonalView.isError ? ( - { - void workspaceCatalogDatabase.refetch(); - void workspaceCatalogPersonalView.refetch(); - }} - retrying={ - workspaceCatalogDatabase.isFetching || - workspaceCatalogPersonalView.isFetching - } - /> - ) : ( - { - if (!workspaceCatalogDatabaseId) return; - const current = workspaceCatalogPersonalView.data?.overrides; - updateWorkspaceCatalogPersonalView.mutate({ - databaseId: workspaceCatalogDatabaseId, - overrides: { - version: - current?.version ?? - CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, - activeViewId: viewId, - views: current?.views ?? [], - }, - }); - }} - manualReorder={ - moveWorkspaceItem.isPending - ? undefined - : { - labels: sidebarReorderLabels, - onReorder: handleWorkspaceReorder, - } - } - renderItem={(item, reorder) => { - const space = contentSpaces.find( - (candidate) => - candidate.catalogDocumentId === item.document.id, - ); - return space - ? renderWorkspaceRoot( - { - ...space, - name: item.document.title || space.name, - }, - reorder, - ) - : null; - }} - scroll={false} - labels={{ - noMatchesLabel: t("database.noRowsMatchThisView"), - clearLabel: t("database.clearSearchAndFilters"), - navigationLabel: "Content navigation", - untitledLabel: t("sidebar.untitled"), - }} - /> - )} -
- - - -
+ {renderWorkspaceRoot(selectedSpace, undefined, true)}
) : contentSpaceState === "loading" ? ( renderTreeSkeleton() @@ -2365,7 +2519,7 @@ export function DocumentSidebar({ />
{renderCollapsedNewButton()} - {renderSearchButton()} + {collapsedSearchButton}
- -
{renderSearchButton()}
+ {contentSpaceSelector} +
{searchButton}
-
- {/* Pinned */} - {showFavorites && ( -
-
- - - {t("sidebar.pinned")} - -
- {!collapsedSections.favorites ? ( - favoritesDatabase.isError ? ( +
+ {selectedSpace ? ( + { + const serverOrdered = favoritesOrder.order.mode !== "custom"; + const renderedItems = ( + serverOrdered + ? (favoritesData?.items ?? []) + : contentSidebarOrderedItems( + favoritesData?.items ?? [], + favoritesOrder.order, + ) + ).slice(0, limit); + return favoritesDatabase.isError || + favoritesPersonalView.isError ? ( void favoritesDatabase.refetch()} - retrying={favoritesDatabase.isFetching} + onRetry={() => { + void favoritesDatabase.refetch(); + void favoritesPersonalView.refetch(); + }} /> ) : ( + handlePinnedReorder( + itemIds, + renderedItems.map((item) => item.id), + ), + }} onOpenItem={(item) => { - const document = documents.find( - (candidate) => candidate.id === item.document.id, + const space = contentSpaces.find( + (candidate) => + candidate.filesDatabaseId === + item.workspaceFilesDatabaseId, ); - const space = document - ? contentSpaces.find( - (candidate) => - candidate.filesDocumentId === - document.databaseMembership?.databaseDocumentId, - ) - : undefined; if (!space || selectedSpace?.id === space.id) { onNavigate?.(); return false; @@ -2527,12 +2671,10 @@ export function DocumentSidebar({ untitledLabel: t("sidebar.untitled"), }} /> - ) - ) : null} -
- )} - - {renderWorkspaceNavigation()} + ); + }} + /> + ) : null} {renderTrashSection()}
diff --git a/templates/content/app/components/sidebar/PersonalSidebarSections.tsx b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx new file mode 100644 index 00000000000..529ddf688ec --- /dev/null +++ b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx @@ -0,0 +1,438 @@ +import { + useActionMutation, + useActionQuery, +} from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { + contentRecentHref, + contentRecentTargetKey, + defaultContentSidebarSections, + type ContentSidebarSections, + type ContentSidebarSectionId, +} from "@shared/content-personal-navigation"; +import { + IconChevronDown, + IconChevronRight, + IconClock, + IconDots, + IconFiles, + IconPin, +} from "@tabler/icons-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + useEffect, + useRef, + useState, + type PointerEventHandler, + type ReactNode, +} from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; + +import { QueryErrorState } from "@/components/QueryErrorState"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useContentRecent } from "@/hooks/use-content-recent"; +import { cn } from "@/lib/utils"; + +import { contentSpaceActionArgs } from "./select-content-space"; +import { + SidebarReorderProvider, + useSidebarReorderItem, + type SidebarReorderLabels, +} from "./sidebar-reorder"; +import { SidebarNavigationRow } from "./SidebarNavigationRow"; + +export function PersonalSidebarSections({ + renderPinned, + pinnedCount, + renderFiles, + spaceId, + onNavigate, + reorderLabels, + seeAllHrefs, +}: { + renderPinned: (limit: number) => ReactNode; + pinnedCount: number; + renderFiles: () => ReactNode; + spaceId: string; + onNavigate?: () => void; + reorderLabels: SidebarReorderLabels; + seeAllHrefs: Record; +}) { + const t = useT(); + const queryClient = useQueryClient(); + const stateArgs = contentSpaceActionArgs(spaceId); + const state = useActionQuery("get-content-sidebar-state", stateArgs); + const recent = useContentRecent(spaceId); + const update = useActionMutation("update-content-sidebar-state", { + skipActionQueryInvalidation: true, + }); + const [optimisticBySpace, setOptimisticBySpace] = useState( + () => new Map(), + ); + const pendingBySpace = useRef(new Map()); + const queueBySpace = useRef(new Map>()); + const [limits, setLimits] = useState({ pinned: 5, recent: 5 }); + useEffect(() => setLimits({ pinned: 5, recent: 5 }), [spaceId]); + const sections = + optimisticBySpace.get(spaceId) ?? + state.data?.state?.sections ?? + defaultContentSidebarSections(); + function save(next: ContentSidebarSections) { + const targetSpaceId = spaceId; + const targetStateKey = [ + "action", + "get-content-sidebar-state", + contentSpaceActionArgs(targetSpaceId), + ]; + setOptimisticBySpace((current) => { + const updated = new Map(current); + updated.set(targetSpaceId, next); + return updated; + }); + pendingBySpace.current.set( + targetSpaceId, + (pendingBySpace.current.get(targetSpaceId) ?? 0) + 1, + ); + const queued = ( + queueBySpace.current.get(targetSpaceId) ?? Promise.resolve() + ) + .catch(() => undefined) + .then(async () => { + try { + const saved = await update.mutateAsync({ + version: 2, + spaceId: targetSpaceId, + sections: next, + }); + queryClient.setQueryData(targetStateKey, saved); + } catch (error) { + toast.error(t("sidebar.failedSaveSidebarState")); + throw error; + } finally { + const pending = (pendingBySpace.current.get(targetSpaceId) ?? 1) - 1; + if (pending > 0) { + pendingBySpace.current.set(targetSpaceId, pending); + } else { + pendingBySpace.current.delete(targetSpaceId); + setOptimisticBySpace((current) => { + const updated = new Map(current); + updated.delete(targetSpaceId); + return updated; + }); + } + } + }); + queueBySpace.current.set(targetSpaceId, queued); + // The error is displayed above; keep the queue usable for a later explicit change. + void queued.catch(() => undefined); + } + function change( + id: ContentSidebarSectionId, + patch: Partial, + ) { + save({ ...sections, [id]: { ...sections[id], ...patch } }); + } + const labels = { + pinned: t("sidebar.pinned"), + recent: t("sidebar.recent"), + files: t("sidebar.files"), + }; + function canShowMore(id: "pinned" | "recent") { + const count = + id === "pinned" ? pinnedCount : (recent.data?.entries.length ?? 0); + if (limits[id] >= 50) return false; + return count > limits[id]; + } + if (state.isError) + return ( + <> + void state.refetch()} /> + {renderFiles()} + + ); + if (state.isLoading) + return ( + <> + + {renderFiles()} + + ); + return ( + <> + ({ + id, + label: labels[id], + parentId: null, + }))} + labels={reorderLabels} + onReorder={(ids) => + save({ ...sections, order: ids as ContentSidebarSectionId[] }) + } + > + {sections.order.map((id) => + id === "files" ? ( + + change("files", { expanded: !sections.files.expanded }) + } + reorderLabels={reorderLabels} + seeAllHref={seeAllHrefs[id]} + sections={sections} + labels={labels} + onChangeVisible={(sectionId, visible) => + change(sectionId, { visible }) + } + > + {sections.files.expanded ? renderFiles() : null} + + ) : sections[id].visible ? ( + { + if (sections[id].expanded) { + setLimits((current) => ({ ...current, [id]: 5 })); + } + change(id, { expanded: !sections[id].expanded }); + }} + reorderLabels={reorderLabels} + seeAllHref={seeAllHrefs[id]} + sections={sections} + labels={labels} + onChangeVisible={(sectionId, visible) => + change(sectionId, { visible }) + } + > + {sections[id].expanded && ( + <> + {id === "pinned" ? ( + renderPinned(limits.pinned) + ) : recent.isError ? ( + void recent.refetch()} + retrying={recent.isFetching} + /> + ) : recent.isLoading ? ( + + ) : recent.data?.entries.length ? ( + + ) : ( +

+ {t("sidebar.noRecentVisits")} +

+ )} + {canShowMore(id) ? ( + + ) : limits[id] > 5 ? ( + + ) : null} + + )} +
+ ) : null, + )} +
+ + ); +} + +function PersonalSection({ + id, + label, + expanded, + onToggle, + reorderLabels, + seeAllHref, + sections, + labels, + onChangeVisible, + children, +}: { + id: ContentSidebarSectionId; + label: string; + expanded?: boolean; + onToggle?: () => void; + reorderLabels: SidebarReorderLabels; + seeAllHref: string; + sections: ContentSidebarSections; + labels: Record; + onChangeVisible: (id: "pinned" | "recent", visible: boolean) => void; + children: ReactNode; +}) { + const t = useT(); + const reorder = useSidebarReorderItem(id); + const pointerDragListener = ( + reorder.listeners as typeof reorder.listeners & { + onPointerDown?: PointerEventHandler; + } + )?.onPointerDown; + const SectionIcon = + id === "pinned" ? IconPin : id === "recent" ? IconClock : IconFiles; + return ( +
+
+ {onToggle && ( + + )} + + + + + + + {t("sidebar.seeAll")} + + + + + {reorderLabels.moveUp} + + + {reorderLabels.moveDown} + + + + {/* Every section menu carries the visibility toggles, so a hidden + section stays restorable from the sections that remain. */} + + {(["pinned", "recent"] as const).map((sectionId) => ( + + onChangeVisible(sectionId, visible) + } + > + {labels[sectionId]} + + ))} + + + +
+ {children} +
+ ); +} diff --git a/templates/content/app/components/sidebar/SidebarNavigationRow.tsx b/templates/content/app/components/sidebar/SidebarNavigationRow.tsx new file mode 100644 index 00000000000..b3be024ab59 --- /dev/null +++ b/templates/content/app/components/sidebar/SidebarNavigationRow.tsx @@ -0,0 +1,42 @@ +import { IconFileText } from "@tabler/icons-react"; +import type { ComponentProps } from "react"; +import { Link } from "react-router"; + +import { cn } from "@/lib/utils"; + +export function SidebarNavigationRow({ + icon, + hideIconOnHover = false, + className, + children, + ...props +}: ComponentProps & { + icon: string | null | undefined; + hideIconOnHover?: boolean; +}) { + return ( + + + {children} + + ); +} 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 d35e3da854e..36783cd16e9 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it, vi } from "vitest"; import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; import { + contentSpaceActionArgs, contentSpaceAvailability, contentSpaceForStoredSelection, contentSpaceForCatalogItem, + contentSidebarSubsetReorder, + contentSpaceRouteReconciliation, contentSpaceIdForCreate, createContentSidebarStateWriteQueue, createContentSpaceSelectionQueue, @@ -14,6 +17,15 @@ import { toggleExpandedWorkspaceIds, } from "./select-content-space"; +describe("contentSpaceActionArgs", () => { + it("omits action input until a non-empty Content space is selected", () => { + expect(contentSpaceActionArgs(undefined)).toBeUndefined(); + expect(contentSpaceActionArgs(null)).toBeUndefined(); + expect(contentSpaceActionArgs("")).toBeUndefined(); + expect(contentSpaceActionArgs("space-1")).toEqual({ spaceId: "space-1" }); + }); +}); + function space( overrides: Partial = {}, ): ContentSpaceSummary { @@ -32,6 +44,20 @@ function space( }; } +describe("contentSidebarSubsetReorder", () => { + it("describes only the five rendered pins when more scoped pins are loaded", () => { + const loaded = ["a", "b", "c", "d", "e", "f", "g"]; + const rendered = loaded.slice(0, 5); + expect( + contentSidebarSubsetReorder(["e", "a", "b", "c", "d"], rendered), + ).toEqual({ + operation: "reorder-subset", + itemIds: ["e", "a", "b", "c", "d"], + previousItemIds: ["a", "b", "c", "d", "e"], + }); + }); +}); + describe("selectContentSpace", () => { it("serializes rapid workspace selections", async () => { const enqueue = createContentSpaceSelectionQueue(); @@ -68,7 +94,7 @@ describe("selectContentSpace", () => { events.push(`state:${next.id}`); }, persistSelection: (id) => events.push(`persist:${id}`), - openFiles: (id) => events.push(`open:${id}`), + openSpace: (id) => events.push(`open:${id}`), }), ); @@ -82,10 +108,10 @@ describe("selectContentSpace", () => { expect(events).toEqual([ "state:builder", "persist:builder", - "open:builder-files", + "open:builder", "state:personal", "persist:personal", - "open:personal-files", + "open:personal", ]); }); @@ -100,7 +126,7 @@ describe("selectContentSpace", () => { persistSelection: (id) => { storedSpaceId = id; }, - openFiles: (id) => opened.push(id), + openSpace: (id) => opened.push(id), }); }; const personal = space({ @@ -120,15 +146,11 @@ describe("selectContentSpace", () => { await select(personal); expect(states).toEqual(["personal", "builder", "personal"]); - expect(opened).toEqual([ - "personal-files", - "builder-files", - "personal-files", - ]); + expect(opened).toEqual(["personal", "builder", "personal"]); expect(storedSpaceId).toBe("personal"); }); - it("syncs state before persisting and opening another org workspace", async () => { + it("persists after asynchronous state sync and before opening another org workspace", async () => { const events: string[] = []; const persistSelection = vi.fn((spaceId: string) => { events.push(`persist:${spaceId}`); @@ -136,59 +158,106 @@ describe("selectContentSpace", () => { const syncApplicationState = vi.fn(async () => { events.push("state:space_1"); }); - const openFiles = vi.fn((documentId: string) => { - events.push(`open:${documentId}`); + const openSpace = vi.fn((spaceId: string) => { + events.push(`open:${spaceId}`); }); await selectContentSpace({ space: space(), syncApplicationState, persistSelection, - openFiles, + openSpace, }); expect(events).toEqual([ "state:space_1", "persist:space_1", - "open:files_document_1", + "open:space_1", ]); }); - it("does not persist or navigate when application state cannot be updated", async () => { + it("does not persist the explicit selection when application state cannot be updated", async () => { const error = new Error("Application state failed"); const persistSelection = vi.fn(); - const openFiles = vi.fn(); + const openSpace = vi.fn(); await expect( selectContentSpace({ space: space(), syncApplicationState: async () => Promise.reject(error), persistSelection, - openFiles, + openSpace, }), ).rejects.toBe(error); expect(persistSelection).not.toHaveBeenCalled(); - expect(openFiles).not.toHaveBeenCalled(); + expect(openSpace).not.toHaveBeenCalled(); }); it("persists and opens the selected Files database", async () => { const persistSelection = vi.fn(); const syncApplicationState = vi.fn(async () => undefined); - const openFiles = vi.fn(); + const openSpace = vi.fn(); await selectContentSpace({ space: space(), syncApplicationState, persistSelection, - openFiles, + openSpace, }); expect(persistSelection).toHaveBeenCalledWith("space_1"); expect(syncApplicationState).toHaveBeenCalledWith( expect.objectContaining({ id: "space_1" }), ); - expect(openFiles).toHaveBeenCalledWith("files_document_1"); + expect(openSpace).toHaveBeenCalledWith("space_1"); + }); +}); + +describe("contentSpaceRouteReconciliation", () => { + const personal = space({ + id: "personal", + filesDatabaseId: "personal-files-db", + }); + const demo = space({ id: "demo", filesDatabaseId: "demo-files-db" }); + + it("does not let a stale route response snap an asynchronous explicit selection back", () => { + expect( + contentSpaceRouteReconciliation({ + activeDocumentId: "demo-files-document", + routeDocumentId: "demo-files-document", + routeFilesDatabaseId: "personal-files-db", + selectedSpace: demo, + explicitSpaceId: "demo", + spaces: [personal, demo], + }), + ).toEqual({ explicitSelectionReachedRoute: false, routeSpace: null }); + }); + + it("releases explicit selection ownership only when the target route response arrives", () => { + expect( + contentSpaceRouteReconciliation({ + activeDocumentId: "demo-files-document", + routeDocumentId: "demo-files-document", + routeFilesDatabaseId: "demo-files-db", + selectedSpace: demo, + explicitSpaceId: "demo", + spaces: [personal, demo], + }), + ).toEqual({ explicitSelectionReachedRoute: true, routeSpace: null }); + }); + + it("preserves direct deep-link reconciliation without an explicit selection", () => { + expect( + contentSpaceRouteReconciliation({ + activeDocumentId: "demo-page", + routeDocumentId: "demo-page", + routeFilesDatabaseId: "demo-files-db", + selectedSpace: personal, + explicitSpaceId: null, + spaces: [personal, demo], + }).routeSpace, + ).toBe(demo); }); }); @@ -324,6 +393,22 @@ describe("contentSpaceIdForCreate", () => { }); describe("contentSpaceForStoredSelection", () => { + it("recovers an explicit stored selection after an initially partial space list", () => { + const personal = space({ id: "personal", kind: "personal" }); + const demo = space({ id: "demo" }); + const storedSpaceId = "demo"; + + expect( + contentSpaceForStoredSelection({ spaces: [personal], storedSpaceId }), + ).toBe(personal); + expect( + contentSpaceForStoredSelection({ + spaces: [personal, demo], + storedSpaceId, + }), + ).toBe(demo); + }); + it("keeps the stored workspace independently of framework organization context", () => { const selected = space({ id: "space_2", orgId: "org_1" }); expect( diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index 8bb7311d38a..30fe4aca6d1 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -41,6 +41,10 @@ export function contentSpaceForStoredSelection(args: { ); } +export function contentSpaceActionArgs(spaceId: string | null | undefined) { + return spaceId ? { spaceId } : undefined; +} + export function contentSpaceForCatalogItem(args: { databaseId: string; catalogDatabaseId: string | undefined; @@ -89,11 +93,42 @@ export async function selectContentSpace(args: { space: ContentSpaceSummary; syncApplicationState: (space: ContentSpaceSummary) => Promise; persistSelection: (spaceId: string) => void; - openFiles: (documentId: string) => void; + openSpace: (spaceId: string) => void; }) { await args.syncApplicationState(args.space); args.persistSelection(args.space.id); - args.openFiles(args.space.filesDocumentId); + args.openSpace(args.space.id); +} + +export function contentSpaceRouteReconciliation(args: { + activeDocumentId: string | null; + routeDocumentId: string | undefined; + routeFilesDatabaseId: string | null; + selectedSpace: ContentSpaceSummary | null; + explicitSpaceId: string | null; + spaces: ContentSpaceSummary[]; +}) { + const explicitSelectionReachedRoute = Boolean( + args.explicitSpaceId && + args.selectedSpace?.id === args.explicitSpaceId && + args.selectedSpace.filesDatabaseId === args.routeFilesDatabaseId, + ); + if ( + !args.activeDocumentId || + args.routeDocumentId !== args.activeDocumentId || + !args.routeFilesDatabaseId || + args.selectedSpace?.filesDatabaseId === args.routeFilesDatabaseId || + (args.explicitSpaceId && !explicitSelectionReachedRoute) + ) { + return { explicitSelectionReachedRoute, routeSpace: null }; + } + return { + explicitSelectionReachedRoute, + routeSpace: + args.spaces.find( + (space) => space.filesDatabaseId === args.routeFilesDatabaseId, + ) ?? null, + }; } export function createContentSpaceSelectionQueue() { @@ -115,3 +150,14 @@ export function createContentSidebarStateWriteQueue( return next; }; } + +export function contentSidebarSubsetReorder( + itemIds: string[], + renderedItemIds: string[], +) { + return { + operation: "reorder-subset" as const, + itemIds, + previousItemIds: renderedItemIds, + }; +} diff --git a/templates/content/app/components/sidebar/sidebar-reorder.test.ts b/templates/content/app/components/sidebar/sidebar-reorder.test.ts index 2d2a6dbe792..da83d2a9242 100644 --- a/templates/content/app/components/sidebar/sidebar-reorder.test.ts +++ b/templates/content/app/components/sidebar/sidebar-reorder.test.ts @@ -1,6 +1,8 @@ // @vitest-environment happy-dom -import { describe, expect, it } from "vitest"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { constrainedSidebarTransform, @@ -8,6 +10,8 @@ import { isPointerSidebarDrag, reorderedSidebarItemIds, sidebarReorderAnnouncement, + SidebarReorderProvider, + useSidebarReorderItem, } from "./sidebar-reorder"; const items = [ @@ -17,6 +21,99 @@ const items = [ { id: "child-b", label: "Child B", parentId: "one" }, ]; +describe("sidebar keyboard activation", () => { + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it.each([ + ["link", "Enter", "Enter", false], + ["link", " ", "Space", true], + ["control", "Enter", "Enter", true], + ["nested-control", "Enter", "Enter", false], + ] as const)( + "preserves %s activation with %s", + async (origin, key, code, startsDrag) => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + function Row() { + const reorder = useSidebarReorderItem("one"); + return createElement( + origin === "link" ? "a" : origin === "control" ? "button" : "div", + { + ...reorder.attributes, + ...reorder.listeners, + ref: reorder.setNodeRef, + role: origin === "link" ? "link" : "button", + href: origin === "link" ? "/page/one" : undefined, + "data-dragging": String(reorder.isDragging), + }, + origin === "nested-control" + ? createElement("button", { type: "button" }, "Menu") + : "One", + ); + } + try { + await act(async () => { + root.render( + createElement(SidebarReorderProvider, { + items: [items[0]], + labels: { + drag: (label: string) => `Drag ${label}`, + moveUp: "Move up", + moveDown: "Move down", + moveTo: "Move to", + moveToPosition: (position: number) => `Position ${position}`, + }, + onReorder: vi.fn(), + children: createElement(Row), + }), + ); + }); + const row = container.querySelector("[data-dragging]")!; + const target = + origin === "nested-control" ? row.querySelector("button")! : row; + target.focus(); + const enter = new KeyboardEvent("keydown", { + key, + code, + bubbles: true, + cancelable: true, + }); + await act(async () => { + target.dispatchEvent(enter); + }); + expect(enter.defaultPrevented).toBe(startsDrag); + expect(row.dataset.dragging).toBe(String(startsDrag)); + if (startsDrag) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await act(async () => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + code: "Escape", + bubbles: true, + cancelable: true, + }), + ); + }); + expect(row.dataset.dragging).toBe("false"); + } + } finally { + await act(async () => { + root.unmount(); + }); + container.remove(); + } + }, + ); +}); + describe("reorderedSidebarItemIds", () => { it("reorders references within one sibling set", () => { expect(reorderedSidebarItemIds(items, "one", "two")).toEqual([ diff --git a/templates/content/app/components/sidebar/sidebar-reorder.tsx b/templates/content/app/components/sidebar/sidebar-reorder.tsx index d95fec57104..71e2165b8a5 100644 --- a/templates/content/app/components/sidebar/sidebar-reorder.tsx +++ b/templates/content/app/components/sidebar/sidebar-reorder.tsx @@ -25,6 +25,7 @@ import { useRef, useState, type CSSProperties, + type KeyboardEvent, type ReactNode, } from "react"; @@ -376,7 +377,21 @@ export function useSidebarReorderItem(itemId: string) { opacity: sortable.isDragging ? 0.55 : undefined, } satisfies CSSProperties, attributes: sortable.attributes, - listeners: sortable.listeners, + listeners: sortable.listeners + ? { + ...sortable.listeners, + onKeyDown: (event: KeyboardEvent) => { + if (event.target !== event.currentTarget) return; + // Enter follows a row link; Space remains its keyboard drag activator. + if ( + event.key === "Enter" && + event.currentTarget.matches('a[href], [role="link"]') + ) + return; + sortable.listeners?.onKeyDown?.(event); + }, + } + : undefined, itemId, isDragging: sortable.isDragging, dropIndicator, diff --git a/templates/content/app/hooks/content-action-refresh.ts b/templates/content/app/hooks/content-action-refresh.ts index 0098b24932e..46c3f3d1f49 100644 --- a/templates/content/app/hooks/content-action-refresh.ts +++ b/templates/content/app/hooks/content-action-refresh.ts @@ -232,6 +232,19 @@ function queryTargetsActiveDatabasePresentation(query: ActionQuery): boolean { ); } +function queryTargetsActiveNavigationOrRecent(query: ActionQuery): boolean { + if (query.queryKey[0] !== "action" || query.isActive?.() !== true) + return false; + if ( + query.queryKey[1] === "get-content-recent" || + query.queryKey[1] === "get-content-navigation-context" + ) + return true; + if (query.queryKey[1] !== "query-content-database-items") return false; + const args = query.queryKey[2]; + return !!args && typeof args === "object" && "navigation" in args; +} + function isDatabaseLifecycleQuery(query: ActionQuery): boolean { return ( query.queryKey[0] === "action" && @@ -266,6 +279,14 @@ export function contentActionInvalidatePredicate( ): (query: ActionQuery, events: readonly ActionEvent[]) => boolean { const documentId = contentDocumentIdFromPathname(pathname); return (query, events) => { + if ( + queryTargetsActiveNavigationOrRecent(query) && + events.some( + (event) => event.source === "action" && event.key === "update-document", + ) + ) { + return true; + } const args = query.queryKey[2]; const targetId = args && typeof args === "object" diff --git a/templates/content/app/hooks/use-content-database.foreground.test.ts b/templates/content/app/hooks/use-content-database.foreground.test.ts new file mode 100644 index 00000000000..9baa8aeb103 --- /dev/null +++ b/templates/content/app/hooks/use-content-database.foreground.test.ts @@ -0,0 +1,92 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const useActionQuery = vi.hoisted(() => vi.fn()); +const useQueryClient = vi.hoisted(() => vi.fn()); +vi.mock("@agent-native/core/client/hooks", () => ({ + useActionQuery, + useActionMutation: vi.fn(), + callAction: vi.fn(), +})); +vi.mock("@tanstack/react-query", async () => ({ + ...(await vi.importActual("@tanstack/react-query")), + useQueryClient, +})); + +import { useContentDatabase } from "./use-content-database"; + +describe("foreground database read after cached creation", () => { + beforeEach(() => { + useActionQuery.mockReset(); + useActionQuery.mockReturnValue({ data: undefined }); + }); + + function observe(refetchOnMount?: "always", reject = false) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 30_000 } }, + }); + useQueryClient.mockReturnValue(client); + const queryKey = [ + "action", + "get-content-database", + { documentId: "database-page", limit: 100 }, + ]; + const data = { database: { id: "database" }, items: [] }; + client.setQueryData(queryKey, data); + function Probe() { + useContentDatabase( + "database-page", + 100, + undefined, + refetchOnMount ? { refetchOnMount } : undefined, + ); + return null; + } + renderToStaticMarkup(createElement(Probe)); + const options = useActionQuery.mock.calls.find( + ([name]) => name === "get-content-database", + )![2]; + const fetch = vi.fn(async () => { + if (reject) throw new Error("Access denied"); + return data; + }); + const observer = new QueryObserver(client, { + ...options, + queryKey, + queryFn: fetch, + }); + const unsubscribe = observer.subscribe(() => {}); + return { client, observer, fetch, unsubscribe }; + } + + it("performs the authoritative read needed to record an already-cached foreground View", async () => { + const { client, observer, fetch, unsubscribe } = observe("always"); + await vi.waitFor(() => + expect(observer.getCurrentResult().isFetchedAfterMount).toBe(true), + ); + expect(fetch).toHaveBeenCalledOnce(); + expect(observer.getCurrentResult().isSuccess).toBe(true); + unsubscribe(); + client.clear(); + }); + + it("does not force an extra read for a fresh embedded database", () => { + const { client, observer, fetch, unsubscribe } = observe(); + expect(fetch).not.toHaveBeenCalled(); + expect(observer.getCurrentResult().isFetchedAfterMount).toBe(false); + unsubscribe(); + client.clear(); + }); + + it("does not admit cached data as a successful visit when revalidation fails", async () => { + const { client, observer, unsubscribe } = observe("always", true); + await vi.waitFor(() => + expect(observer.getCurrentResult().isError).toBe(true), + ); + expect(observer.getCurrentResult().isSuccess).toBe(false); + unsubscribe(); + client.clear(); + }); +}); diff --git a/templates/content/app/hooks/use-content-database.test.ts b/templates/content/app/hooks/use-content-database.test.ts index fd5526b8739..ce9385d2ed9 100644 --- a/templates/content/app/hooks/use-content-database.test.ts +++ b/templates/content/app/hooks/use-content-database.test.ts @@ -17,11 +17,13 @@ import { contentDatabaseCreationRequest, contentDatabaseResponseCanSeedQuery, contentDatabaseItemsPageQueryKey, + contentDatabaseItemsContainingDocumentFilter, contentDatabaseConstrainedQueryFilter, contentDatabaseQueryKey, fetchCompleteContentDatabaseList, invalidateBuilderBodyHydrationQueries, invalidateContentDatabaseSourceRefreshQueries, + invalidateContentDatabaseNavigationQueries, isContentDatabaseByIdQueryEnabled, moveOptimisticContentDatabaseItem, preserveScopedDatabasePlaceholder, @@ -209,6 +211,50 @@ describe("contentDatabaseConstrainedQueryFilter", () => { }); }); +describe("Content database navigation query invalidation", () => { + it("matches navigation rows safely and invalidates only the affected database", () => { + const queryClient = new QueryClient(); + const matchingKey = [ + "action", + "query-content-database-items", + { databaseId: "files", navigation: { parentId: null } }, + ] as const; + const otherKey = [ + "action", + "query-content-database-items", + { databaseId: "other", navigation: { parentId: null } }, + ] as const; + const tableKey = [ + "action", + "query-content-database-items", + { databaseId: "files", tableQuery: {} }, + ] as const; + queryClient.setQueryData(matchingKey, { + items: [{ documentId: "page" }], + }); + queryClient.setQueryData(otherKey, { items: [] }); + queryClient.setQueryData(tableKey, { + items: [{ document: { id: "page" } }], + }); + + expect(() => + queryClient.invalidateQueries( + contentDatabaseItemsContainingDocumentFilter("page"), + ), + ).not.toThrow(); + expect(queryClient.getQueryState(matchingKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(tableKey)?.isInvalidated).toBe(true); + + queryClient.resetQueries(); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: "files", + }); + expect(queryClient.getQueryState(matchingKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(tableKey)?.isInvalidated).toBe(false); + }); +}); + describe("isContentDatabaseByIdQueryEnabled", () => { it("fetches when a databaseId is present and the caller doesn't pause it", () => { expect(isContentDatabaseByIdQueryEnabled("files-db")).toBe(true); diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index 0cbd5a35996..6486e387c65 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -17,6 +17,7 @@ import type { ContentDatabaseSourceAttachmentAck, ContentDatabaseSourceAttachmentResult, ContentDatabaseItemsPageResponse, + ContentDatabaseNavigationPageResponse, ContentDatabaseTableQuery, ContentDatabaseItem, ContentDatabasePersonalViewResponse, @@ -64,8 +65,15 @@ export function contentDatabaseQueryKey(documentId: string) { return ["action", "get-content-database", { documentId }] as const; } -export function contentDatabaseByIdQueryKey(databaseId: string) { - return ["action", "get-content-database", { databaseId }] as const; +export function contentDatabaseByIdQueryKey( + databaseId: string, + limit?: number, +) { + return [ + "action", + "get-content-database", + { databaseId, ...(limit ? { limit } : {}) }, + ] as const; } export const contentDatabaseItemsPageQueryKey = [ @@ -145,7 +153,11 @@ export function moveOptimisticContentDatabaseItem( export function preserveScopedDatabasePlaceholder( previous: T | undefined, previousQuery: Pick | undefined, - scope: { documentId?: string; databaseId?: string }, + scope: { + documentId?: string; + databaseId?: string; + contentSpaceId?: string; + }, ): T | undefined { const previousParams = previousQuery?.queryKey[2]; if (!previousParams || typeof previousParams !== "object") return undefined; @@ -153,12 +165,16 @@ export function preserveScopedDatabasePlaceholder( const params = previousParams as { documentId?: unknown; databaseId?: unknown; + contentSpaceId?: unknown; }; if (scope.documentId !== undefined) { return params.documentId === scope.documentId ? previous : undefined; } if (scope.databaseId !== undefined) { - return params.databaseId === scope.databaseId ? previous : undefined; + return params.databaseId === scope.databaseId && + params.contentSpaceId === scope.contentSpaceId + ? previous + : undefined; } return undefined; } @@ -214,14 +230,43 @@ export function contentDatabaseItemsContainingDocumentFilter( predicate: (query: Query) => { const data = query.state.data as | ContentDatabaseItemsPageResponse + | ContentDatabaseNavigationPageResponse | undefined; return ( - data?.items.some((item) => item.document.id === documentId) === true + data?.items.some((item) => + "document" in item + ? item.document.id === documentId + : item.documentId === documentId, + ) === true ); }, }; } +export function invalidateContentDatabaseNavigationQueries( + queryClient: Pick, + scope: { databaseId?: string; parentId?: string | null } = {}, +) { + void queryClient.invalidateQueries({ + queryKey: contentDatabaseItemsPageQueryKey, + predicate: (query) => { + const params = query.queryKey[2]; + if (!params || typeof params !== "object") return false; + const args = params as { + databaseId?: unknown; + navigation?: { parentId?: unknown }; + }; + if (!args.navigation) return false; + if (scope.databaseId && args.databaseId !== scope.databaseId) + return false; + return ( + scope.parentId === undefined || + args.navigation.parentId === scope.parentId + ); + }, + }); +} + export function writeContentDatabaseResponseToCache( queryClient: Pick, documentId: string, @@ -664,7 +709,7 @@ export function useContentDatabase( documentId: string | null, limit?: number, tableQuery?: ContentDatabaseTableQuery, - options?: { systemRole?: string | null }, + options?: { refetchOnMount?: "always"; systemRole?: string | null }, ) { const queryClient = useQueryClient(); const baseQuery = useActionQuery( @@ -684,6 +729,7 @@ export function useContentDatabase( // Cross-key seeds (e.g. a differently-paginated cached response) render // instantly but must refetch immediately, not sit fresh for staleTime. initialDataUpdatedAt: 0, + refetchOnMount: options?.refetchOnMount, meta: { contentDatabaseSystemRole: options?.systemRole }, }, ); @@ -738,17 +784,31 @@ export function isContentDatabaseByIdQueryEnabled( export function useContentDatabaseById( databaseId: string | null, - options?: { enabled?: boolean; systemRole?: string | null }, + options?: { + enabled?: boolean; + limit?: number; + contentSpaceId?: string; + systemRole?: string | null; + }, ) { return useActionQuery( "get-content-database", - databaseId ? { databaseId } : undefined, + databaseId + ? { + databaseId, + ...(options?.limit ? { limit: options.limit } : {}), + ...(options?.contentSpaceId + ? { contentSpaceId: options.contentSpaceId } + : {}), + } + : undefined, { enabled: isContentDatabaseByIdQueryEnabled(databaseId, options), retry: false, placeholderData: (previous, previousQuery) => preserveScopedDatabasePlaceholder(previous, previousQuery, { databaseId: databaseId ?? undefined, + contentSpaceId: options?.contentSpaceId, }), meta: { contentDatabaseSystemRole: options?.systemRole }, }, @@ -779,6 +839,7 @@ export function useCreateContentDatabase( queryKey: ["action", "list-documents"], }); } + invalidateContentDatabaseNavigationQueries(queryClient); }, }, ); @@ -822,6 +883,7 @@ export function useCreateInlineContentDatabase(hostDocumentId: string | null) { void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient); }, }); } @@ -834,14 +896,19 @@ export function useDeleteContentDatabase() { databaseId: string; documentId: string; deletedAt: string; + activeTargetDeleted: boolean; + navigationPath: string | null; }, - { databaseId: string } + { databaseId: string; activeDocumentId?: string } >("delete-content-database", { onSuccess: (data) => { clearDeletedContentDatabaseFromCache(queryClient, data.documentId); void queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.databaseId, + }); }, }); } @@ -857,7 +924,7 @@ export function useRestoreContentDatabase() { }, { databaseId: string } >("restore-content-database", { - onSuccess: () => { + onSuccess: (data) => { void queryClient.invalidateQueries({ queryKey: ["action", "get-content-database"], }); @@ -876,6 +943,9 @@ export function useRestoreContentDatabase() { void queryClient.invalidateQueries({ queryKey: ["action", "list-content-databases"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.databaseId, + }); }, }); } @@ -928,6 +998,9 @@ export function useAddDatabaseItem(documentId: string) { void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.receipt.target.databaseId, + }); }, }); } @@ -980,13 +1053,16 @@ export function useDuplicateDatabaseItems(documentId: string) { return useActionMutation( "duplicate-database-items", { - onSuccess: () => { + onSuccess: (data) => { void queryClient.invalidateQueries({ queryKey: contentDatabaseQueryKey(documentId), }); void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.database.id, + }); }, }, ); @@ -997,13 +1073,16 @@ export function useRemoveDatabaseItems(documentId: string) { return useActionMutation( "remove-database-items", { - onSuccess: () => { + onSuccess: (data) => { void queryClient.invalidateQueries({ queryKey: contentDatabaseQueryKey(documentId), }); void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.database.id, + }); }, }, ); @@ -1073,6 +1152,9 @@ export function useMoveDatabaseItem(documentId: string) { void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: variables.databaseId, + }); }, }, ); diff --git a/templates/content/app/hooks/use-content-personal-navigation.ts b/templates/content/app/hooks/use-content-personal-navigation.ts new file mode 100644 index 00000000000..3fae916a798 --- /dev/null +++ b/templates/content/app/hooks/use-content-personal-navigation.ts @@ -0,0 +1,59 @@ +import { useActionMutation } from "@agent-native/core/client/hooks"; +import type { + ContentDatabasePersonalViewResponse, + ContentDatabaseView, +} from "@shared/api"; +import { applyContentPersonalNavigationPatch } from "@shared/content-personal-navigation-patch"; +import { useQueryClient } from "@tanstack/react-query"; + +export function useUpdateContentPersonalNavigation( + databaseId: string | null, + sharedViews?: ContentDatabaseView[], +) { + const queryClient = useQueryClient(); + const queryKey = [ + "action", + "get-content-database-personal-view", + { databaseId }, + ]; + const mutationKey = ["content-personal-navigation", databaseId]; + return useActionMutation("update-content-database-personal-view", { + mutationKey, + scope: { id: JSON.stringify(mutationKey) }, + skipActionQueryInvalidation: true, + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey }); + const previous = + queryClient.getQueryData(queryKey); + if (variables.navigation) + queryClient.setQueryData(queryKey, { + databaseId, + overrides: applyContentPersonalNavigationPatch( + previous?.overrides ?? null, + variables.navigation, + sharedViews, + ), + }); + return { previous }; + }, + onError: (_error, _variables, context) => { + if (queryClient.isMutating({ mutationKey }) > 1) return; + queryClient.setQueryData( + queryKey, + ( + context as + | { previous?: ContentDatabasePersonalViewResponse } + | undefined + )?.previous, + ); + }, + onSuccess: (data) => { + if (queryClient.isMutating({ mutationKey }) > 1) return; + queryClient.setQueryData(queryKey, data); + }, + onSettled: () => { + if (queryClient.isMutating({ mutationKey }) > 1) return; + void queryClient.invalidateQueries({ queryKey }); + }, + }); +} diff --git a/templates/content/app/hooks/use-content-recent.test.ts b/templates/content/app/hooks/use-content-recent.test.ts new file mode 100644 index 00000000000..9849aedac26 --- /dev/null +++ b/templates/content/app/hooks/use-content-recent.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { contentRecentQueryArgs } from "./use-content-recent"; + +describe("contentRecentQueryArgs", () => { + it("never serializes an empty optional space ID", () => { + expect(contentRecentQueryArgs(undefined, undefined)).toBeUndefined(); + expect(contentRecentQueryArgs("scope", undefined)).toEqual({ + scopeKey: "scope", + }); + expect(contentRecentQueryArgs("scope", "")).toEqual({ scopeKey: "scope" }); + expect(contentRecentQueryArgs("scope", "space-1")).toEqual({ + scopeKey: "scope", + spaceId: "space-1", + }); + }); +}); diff --git a/templates/content/app/hooks/use-content-recent.ts b/templates/content/app/hooks/use-content-recent.ts new file mode 100644 index 00000000000..94b2000c9ca --- /dev/null +++ b/templates/content/app/hooks/use-content-recent.ts @@ -0,0 +1,87 @@ +import { + useActionMutation, + useActionQuery, +} from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { useOrg } from "@agent-native/core/client/org"; +import { + contentRecentVisitKey, + type ContentRecentTarget, +} from "@shared/content-personal-navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; + +export function contentRecentQueryArgs( + scopeKey: string | undefined, + spaceId?: string, +) { + if (!scopeKey) return undefined; + return { scopeKey, ...(spaceId ? { spaceId } : {}) }; +} + +export function useContentRecent(spaceId?: string) { + const org = useOrg(); + const scopeKey = org.data + ? JSON.stringify([ + org.data.email.trim().toLowerCase(), + org.data.orgId ?? null, + spaceId ?? null, + ]) + : undefined; + const query = useActionQuery( + "get-content-recent", + contentRecentQueryArgs(scopeKey, spaceId), + { + enabled: Boolean(scopeKey) && !org.isFetching, + placeholderData: undefined, + }, + ); + return { + ...query, + data: + !org.isFetching && query.data?.scopeKey === scopeKey + ? query.data + : undefined, + isLoading: org.isLoading || org.isFetching || query.isLoading, + isError: org.isError || query.isError, + }; +} + +export function useContentVisitRecorder() { + const queryClient = useQueryClient(); + const t = useT(); + const mutation = useActionMutation("record-content-visit", { + skipActionQueryInvalidation: true, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-recent"], + }); + }, + onError: () => { + toast.error(t("sidebar.failedSaveSidebarState")); + }, + }); + const mutationRef = useRef(mutation); + mutationRef.current = mutation; + return useCallback((target: ContentRecentTarget) => { + mutationRef.current.mutate(target); + }, []); +} + +export function useRecordContentVisit( + target: ContentRecentTarget | null, + enabled: boolean, +) { + const record = useContentVisitRecorder(); + const targetRef = useRef(target); + targetRef.current = target; + const key = target ? contentRecentVisitKey(target) : null; + const recordedKey = useRef(null); + useEffect(() => { + if (!enabled || !key) return; + if (recordedKey.current === key || !targetRef.current) return; + recordedKey.current = key; + record(targetRef.current); + }, [enabled, key, record]); +} diff --git a/templates/content/app/hooks/use-content-spaces.ts b/templates/content/app/hooks/use-content-spaces.ts index 0400ad759f9..655b54b5b9e 100644 --- a/templates/content/app/hooks/use-content-spaces.ts +++ b/templates/content/app/hooks/use-content-spaces.ts @@ -18,6 +18,7 @@ export type ContentSpaceSummary = { }; export type ListContentSpacesResponse = { + sourceMode: "database" | "local-files"; catalogDatabaseId: string; catalogDocumentId: string; favoritesDatabaseId: string | null; diff --git a/templates/content/app/hooks/use-db-sync.spec.ts b/templates/content/app/hooks/use-db-sync.spec.ts index 23f035bed73..4aedbfe3b6c 100644 --- a/templates/content/app/hooks/use-db-sync.spec.ts +++ b/templates/content/app/hooks/use-db-sync.spec.ts @@ -842,6 +842,56 @@ describe("contentActionInvalidatePredicate", () => { ), ).toBe(false); }); + + it("refreshes active navigation and Recent after an external rename on any route", () => { + const predicate = contentActionInvalidatePredicate("/settings"); + const event = [{ source: "action", key: "update-document" }]; + + expect( + predicate( + { + queryKey: [ + "action", + "query-content-database-items", + { databaseId: "files", navigation: { parentId: null } }, + ], + isActive: () => true, + }, + event, + ), + ).toBe(true); + expect( + predicate( + { + queryKey: ["action", "get-content-recent", { scopeKey: "user" }], + isActive: () => true, + }, + event, + ), + ).toBe(true); + expect( + predicate( + { + queryKey: [ + "action", + "get-content-navigation-context", + { id: "document" }, + ], + isActive: () => true, + }, + event, + ), + ).toBe(true); + expect( + predicate( + { + queryKey: ["action", "get-content-recent", { scopeKey: "user" }], + isActive: () => false, + }, + event, + ), + ).toBe(false); + }); }); describe("contentDocumentIdFromPathname", () => { diff --git a/templates/content/app/hooks/use-document-title-mutation.test.ts b/templates/content/app/hooks/use-document-title-mutation.test.ts index f75ccb1e0cb..f37ad91dece 100644 --- a/templates/content/app/hooks/use-document-title-mutation.test.ts +++ b/templates/content/app/hooks/use-document-title-mutation.test.ts @@ -3,24 +3,31 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const useActionMutation = vi.hoisted(() => vi.fn()); const useQueryClient = vi.hoisted(() => vi.fn()); +const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn() })); vi.mock("@agent-native/core/client/hooks", () => ({ useActionMutation, useActionQuery: vi.fn(), callAction: vi.fn(), })); +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string) => key, +})); +vi.mock("sonner", () => ({ toast })); vi.mock("@tanstack/react-query", async () => ({ ...(await vi.importActual("@tanstack/react-query")), useQueryClient, })); -import { useUpdateDocument } from "./use-documents"; +import { LIST_DOCUMENTS_QUERY_KEY, useUpdateDocument } from "./use-documents"; describe("title changes and database query membership", () => { beforeEach(() => { useActionMutation.mockReset(); useQueryClient.mockReset(); + toast.mockClear(); + toast.error.mockClear(); useActionMutation.mockImplementation((_name, options) => options); }); @@ -102,4 +109,191 @@ describe("title changes and database query membership", () => { } }, ); + + it("optimistically patches loaded navigation and Recent titles, then invalidates both", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }); + useQueryClient.mockReturnValue(client); + const navigationKey = [ + "action", + "query-content-database-items", + { databaseId: "files", navigation: { parentId: null } }, + ]; + const recentKey = ["action", "get-content-recent", { scopeKey: "user" }]; + const navigationContextKey = [ + "action", + "get-content-navigation-context", + { id: "row-1" }, + ]; + client.setQueryData(navigationKey, { + items: [{ documentId: "row-1", title: "Untitled" }], + }); + client.setQueryData(recentKey, { + scopeKey: "user", + entries: [ + { + target: { documentId: "row-1" }, + visitedAt: "2026-09-14T00:00:00.000Z", + title: "Untitled", + icon: null, + viewName: null, + }, + ], + }); + client.setQueryData(navigationContextKey, { + document: { id: "row-1", title: "Untitled" }, + path: [{ id: "row-1", title: "Untitled" }], + }); + + useUpdateDocument(); + const mutation = useActionMutation.mock.calls.find( + ([name]) => name === "update-document", + )![1]; + const context = await mutation.onMutate({ + id: "row-1", + title: "Renamed page", + }); + + expect(client.getQueryData(navigationKey).items[0].title).toBe( + "Renamed page", + ); + expect(client.getQueryData(recentKey).entries[0].title).toBe( + "Renamed page", + ); + expect(client.getQueryData(navigationContextKey).path[0].title).toBe( + "Renamed page", + ); + + mutation.onSuccess( + { + id: "row-1", + title: "Renamed page", + softDeletedDatabaseIds: [], + }, + { id: "row-1", title: "Renamed page" }, + context, + ); + + expect(client.getQueryState(navigationKey)?.isInvalidated).toBe(true); + expect(client.getQueryState(recentKey)?.isInvalidated).toBe(true); + expect(client.getQueryState(navigationContextKey)?.isInvalidated).toBe( + true, + ); + client.clear(); + }); + + it("restores hidden Pinned from click-time sidebar state and rolls back a failed save", async () => { + const client = new QueryClient(); + useQueryClient.mockReturnValue(client); + const saveSidebarState = vi.fn().mockRejectedValue(new Error("failed")); + useActionMutation.mockImplementation((name, options) => + name === "update-content-sidebar-state" + ? { mutateAsync: saveSidebarState } + : options, + ); + const sidebarKey = [ + "action", + "get-content-sidebar-state", + { spaceId: "space-a" }, + ] as const; + const unrelatedSidebarKey = [ + "action", + "get-content-sidebar-state", + { spaceId: "space-b" }, + ] as const; + const hiddenState = { + state: { + version: 2 as const, + sections: { + order: ["pinned", "recent", "workspaces"], + pinned: { visible: false, expanded: false, limit: 5 }, + recent: { visible: true, expanded: true, limit: 5 }, + }, + }, + }; + client.setQueryData(sidebarKey, hiddenState); + client.setQueryData(unrelatedSidebarKey, { + state: { + ...hiddenState.state, + sections: { + ...hiddenState.state.sections, + pinned: { visible: true, expanded: false, limit: 5 }, + }, + }, + }); + client.setQueryData(LIST_DOCUMENTS_QUERY_KEY, [ + { id: "row-1", spaceId: "space-a" }, + ]); + const favoritesResponse = { + database: { + id: "favorites", + systemRole: "favorites", + viewConfig: { activeViewId: "default" }, + }, + items: [], + }; + const favoritesAKey = [ + "action", + "get-content-database", + { databaseId: "favorites", contentSpaceId: "space-a" }, + ] as const; + const favoritesBKey = [ + "action", + "get-content-database", + { databaseId: "favorites", contentSpaceId: "space-b" }, + ] as const; + client.setQueryData(favoritesAKey, favoritesResponse); + client.setQueryData(favoritesBKey, favoritesResponse); + + useUpdateDocument(); + const mutation = useActionMutation.mock.calls.find( + ([name]) => name === "update-document", + )![1]; + const context = await mutation.onMutate({ id: "row-1", isFavorite: true }); + expect(client.getQueryData(favoritesAKey).items).toHaveLength(1); + expect(client.getQueryData(favoritesBKey).items).toHaveLength(0); + mutation.onSuccess( + { + id: "row-1", + isFavorite: true, + softDeletedDatabaseIds: [], + }, + { id: "row-1", isFavorite: true }, + context, + ); + const action = toast.mock.calls[0]![1].action; + const newerState = { + state: { + ...hiddenState.state, + sections: { + ...hiddenState.state.sections, + recent: { visible: false, expanded: false, limit: 10 }, + }, + }, + }; + client.setQueryData(sidebarKey, newerState); + + action.onClick(); + expect(client.getQueryData(sidebarKey).state.sections).toEqual({ + ...newerState.state.sections, + pinned: { visible: true, expanded: true, limit: 5 }, + }); + expect(saveSidebarState).toHaveBeenCalledWith({ + version: 2, + spaceId: "space-a", + sectionsPatch: { pinned: { visible: true, expanded: true } }, + }); + await vi.waitFor(() => { + expect(client.getQueryData(sidebarKey)).toEqual(newerState); + expect(toast.error).toHaveBeenCalledWith( + "sidebar.failedSaveSidebarState", + ); + }); + expect(action.label).toBe("editor.properties.show"); + expect( + client.getQueryData(unrelatedSidebarKey).state.sections.pinned, + ).toEqual({ visible: true, expanded: false, limit: 5 }); + client.clear(); + }); }); diff --git a/templates/content/app/hooks/use-documents.mutations.test.ts b/templates/content/app/hooks/use-documents.mutations.test.ts index 625fd3d1ea2..5b3246d62c5 100644 --- a/templates/content/app/hooks/use-documents.mutations.test.ts +++ b/templates/content/app/hooks/use-documents.mutations.test.ts @@ -2,11 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const useActionMutation = vi.hoisted(() => vi.fn()); const useActionQuery = vi.hoisted(() => vi.fn()); +const invalidateQueries = vi.hoisted(() => vi.fn()); vi.mock("@agent-native/core/client/hooks", () => ({ useActionMutation, useActionQuery, })); +vi.mock("@tanstack/react-query", async (importOriginal) => ({ + ...(await importOriginal()), + useQueryClient: () => ({ invalidateQueries }), +})); import { useCreateDocument, @@ -17,6 +22,7 @@ describe("useUpdatePreviewDocumentDraft", () => { beforeEach(() => { useActionMutation.mockReset(); useActionQuery.mockReset(); + invalidateQueries.mockReset(); }); it("keeps originating-tab draft autosaves out of generic action invalidation", () => { @@ -50,5 +56,12 @@ describe("useCreateDocument", () => { skipActionQueryInvalidation: true, }), ); + const options = useActionMutation.mock.calls[0]?.[1]; + options.onSuccess(); + expect(invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["action", "query-content-database-items"], + }), + ); }); }); diff --git a/templates/content/app/hooks/use-documents.test.ts b/templates/content/app/hooks/use-documents.test.ts index 08b556b3151..e05b830f0a1 100644 --- a/templates/content/app/hooks/use-documents.test.ts +++ b/templates/content/app/hooks/use-documents.test.ts @@ -16,6 +16,7 @@ import { documentQueryKey, filterDocumentTreeDocuments, isDocumentUpdateConflict, + isFavoritesDatabaseCache, mergeDocumentIntoDocumentCache, mergeDocumentIntoListDocumentsCache, patchDocumentCaches, @@ -405,6 +406,11 @@ describe("mergeDocumentIntoListDocumentsCache", () => { }); describe("optimistic document favorites", () => { + it("does not treat an unavailable database cache entry as a Favorites response", () => { + expect( + isFavoritesDatabaseCache({ available: false, reason: "missing" }), + ).toBe(false); + }); it("updates array and object list caches without disturbing other pages", () => { const favorite = { ...doc("a", null), isFavorite: true }; expect( @@ -449,6 +455,39 @@ describe("optimistic document favorites", () => { expect(database.items[0].document.isFavorite).toBe(false); }); + it("updates flat navigation rows without treating them as database rows", () => { + const navigation = { + items: [ + { + membershipId: "item-a", + membershipPosition: 0, + documentId: "a", + title: "A", + icon: null, + isFavorite: false, + }, + { + membershipId: "item-b", + membershipPosition: 1, + documentId: "b", + title: "B", + icon: null, + isFavorite: false, + }, + ], + pagination: { hasMore: false, limit: 20, nextCursor: null }, + } as any; + + const updated = patchDocumentInDatabaseCache(navigation, "a", { + isFavorite: true, + })!; + expect(updated.items[0]).toMatchObject({ + documentId: "a", + isFavorite: true, + }); + expect(updated.items[1]).toBe(navigation.items[1]); + }); + it("removes unfavorited pages from a cached Favorites database", () => { const database = { database: { systemRole: "favorites" }, diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index a7b00ffa7e6..c70d37c4f6b 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -3,6 +3,7 @@ import { useActionQuery, useActionMutation, } from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; import type { ContentDatabaseItemsPageResponse, ContentDatabaseResponse, @@ -18,6 +19,9 @@ import type { ListTrashedDocumentsResponse, DocumentTreeNode, } from "@shared/api"; +import type { ContentSidebarSections } from "@shared/content-personal-navigation"; +import type { ContentRecentResult } from "@shared/content-personal-navigation"; +import { applyContentPersonalNavigationPatch } from "@shared/content-personal-navigation-patch"; import type { QueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -35,6 +39,7 @@ import { import { contentDatabaseConstrainedQueryFilter, contentDatabaseItemsContainingDocumentFilter, + invalidateContentDatabaseNavigationQueries, removeOptimisticItemFromContentDatabase, useRestoreContentDatabase, } from "./use-content-database"; @@ -339,7 +344,10 @@ export function setDocumentFavoriteInListCache( } export function patchDocumentInDatabaseCache< - T extends ContentDatabaseResponse | ContentDatabaseItemsPageResponse, + T extends + | ContentDatabaseResponse + | ContentDatabaseItemsPageResponse + | import("@shared/api").ContentDatabaseNavigationPageResponse, >( current: T | undefined, documentId: string, @@ -348,6 +356,21 @@ export function patchDocumentInDatabaseCache< if (!current) return current; let changed = false; const items = current.items.map((item) => { + if (!("document" in item)) { + if (item.documentId !== documentId) return item; + changed = true; + return { + ...item, + ...(patch.title !== undefined ? { title: patch.title } : {}), + ...(patch.icon !== undefined ? { icon: patch.icon } : {}), + ...(patch.isFavorite !== undefined + ? { isFavorite: patch.isFavorite } + : {}), + ...(patch.updatedAt !== undefined + ? { updatedAt: patch.updatedAt } + : {}), + }; + } if (item.document.id !== documentId) return item; changed = true; return { @@ -369,6 +392,16 @@ export function setDocumentFavoriteInDatabaseCache( return patchDocumentInDatabaseCache(current, documentId, { isFavorite }); } +export function isFavoritesDatabaseCache( + current: unknown, +): current is ContentDatabaseResponse { + if (!current || typeof current !== "object") return false; + return ( + (current as Partial).database?.systemRole === + "favorites" + ); +} + function patchDocumentWithFavoriteMembershipInDatabaseCache( current: ContentDatabaseResponse | undefined, documentId: string, @@ -404,6 +437,38 @@ export function patchDocumentCaches( contentDatabaseItemsContainingDocumentFilter(documentId), (current) => patchDocumentInDatabaseCache(current, documentId, patch), ); + queryClient.setQueriesData<{ entries: ContentRecentResult[] }>( + { queryKey: ["action", "get-content-recent"] }, + (current) => { + if (!current || patch.title === undefined) return current; + let changed = false; + const entries = current.entries.map((entry) => { + if (entry.target.documentId !== documentId) return entry; + changed = true; + return { ...entry, title: patch.title! }; + }); + return changed ? { ...current, entries } : current; + }, + ); + queryClient.setQueriesData<{ + document?: Document; + path?: Array & { id: string }>; + }>({ queryKey: ["action", "get-content-navigation-context"] }, (current) => { + if (!current) return current; + const document = + current.document?.id === documentId + ? { ...current.document, ...patch } + : current.document; + let pathChanged = false; + const path = current.path?.map((entry) => { + if (entry.id !== documentId) return entry; + pathChanged = true; + return { ...entry, ...patch }; + }); + return document !== current.document || pathChanged + ? { ...current, document, path } + : current; + }); } type ContentSpaceNameCache = { @@ -505,7 +570,7 @@ export function seedDatabaseItemDocumentCaches( } } -export function useDocuments() { +export function useDocuments(options?: { enabled?: boolean }) { return useQuery({ queryKey: LIST_DOCUMENTS_QUERY_KEY, queryFn: async ({ signal }) => ({ @@ -519,6 +584,7 @@ export function useDocuments() { }), select: (data) => data.documents, retry: false, + enabled: options?.enabled !== false, }); } @@ -639,15 +705,23 @@ export function useResolvePreviewDocumentDraft() { } export function useCreateDocument() { + const queryClient = useQueryClient(); return useActionMutation( "create-document", - { skipActionQueryInvalidation: true }, + { + skipActionQueryInvalidation: true, + onSuccess: () => invalidateContentDatabaseNavigationQueries(queryClient), + }, ); } export function useUpdateDocument() { const queryClient = useQueryClient(); + const t = useT(); const restoreContentDatabase = useRestoreContentDatabase(); + const updateSidebarState = useActionMutation("update-content-sidebar-state", { + skipActionQueryInvalidation: true, + }); return useActionMutation( "update-document", { @@ -672,12 +746,22 @@ export function useUpdateDocument() { const contentSpacesFilter = { queryKey: ["action", "list-content-spaces"], } as const; + const personalViewFilter = { + queryKey: ["action", "get-content-database-personal-view"], + } as const; + const sidebarStateEntry = currentContentSidebarState( + queryClient, + currentDocumentSpaceId(queryClient, variables.id), + ); + const sidebarStateKey = sidebarStateEntry?.[0]; + const documentSpaceId = sidebarStateKey?.[2].spaceId; await Promise.all([ queryClient.cancelQueries(documentFilter), queryClient.cancelQueries({ queryKey: LIST_DOCUMENTS_QUERY_KEY }), queryClient.cancelQueries(databaseFilter), queryClient.cancelQueries(databasePageFilter), queryClient.cancelQueries(contentSpacesFilter), + queryClient.cancelQueries(personalViewFilter), ]); const previous: Array<[readonly unknown[], unknown]> = [ @@ -693,8 +777,117 @@ export function useUpdateDocument() { databasePageFilter, ), ...queryClient.getQueriesData(contentSpacesFilter), + ...queryClient.getQueriesData(personalViewFilter), + ...(sidebarStateKey + ? [ + [ + sidebarStateKey, + queryClient.getQueryData(sidebarStateKey), + ] as [readonly unknown[], unknown], + ] + : []), ]; + const sidebarState = sidebarStateEntry?.[1]; + const sidebarSpaceId = sidebarStateKey?.[2].spaceId; + const nextSidebarState = + variables.isFavorite === true && + sidebarState?.state?.sections.pinned.visible && + !sidebarState.state.sections.pinned.expanded + ? { + version: 2 as const, + ...(typeof sidebarSpaceId === "string" + ? { spaceId: sidebarSpaceId } + : {}), + sections: { + ...sidebarState.state.sections, + pinned: { + ...sidebarState.state.sections.pinned, + expanded: true, + }, + }, + } + : undefined; + if (nextSidebarState && sidebarStateKey) + queryClient.setQueryData(sidebarStateKey, { + state: nextSidebarState, + }); + + if (variables.isFavorite === true) { + const listSnapshot = queryClient.getQueryData( + LIST_DOCUMENTS_QUERY_KEY, + ); + const documents: Document[] = Array.isArray(listSnapshot) + ? listSnapshot + : ((listSnapshot as DocumentListResponse | undefined)?.documents ?? + []); + const document = documents.find( + (candidate) => candidate.id === variables.id, + ); + if (document) { + for (const [ + databaseKey, + database, + ] of queryClient.getQueriesData({ + queryKey: ["action", "get-content-database"], + })) { + if (!isFavoritesDatabaseCache(database)) continue; + if ( + (databaseKey[2] as { contentSpaceId?: unknown } | undefined) + ?.contentSpaceId !== documentSpaceId + ) + continue; + if ( + database.items.some((item) => item.document.id === variables.id) + ) + continue; + const optimisticItemId = `optimistic-favorite:${variables.id}`; + queryClient.setQueryData(databaseKey, { + ...database, + items: [ + { + id: optimisticItemId, + databaseId: database.database.id, + position: -1, + properties: [], + document: { ...document, isFavorite: true }, + }, + ...database.items, + ], + }); + const personalKey = [ + "action", + "get-content-database-personal-view", + { databaseId: database.database.id }, + ] as const; + queryClient.setQueryData<{ + databaseId: string; + overrides: + | import("@shared/api").ContentDatabasePersonalViewOverrides + | null; + }>(personalKey, (current) => { + if (!current?.overrides) return current; + const activeViewId = + current.overrides.activeViewId ?? + database.database.viewConfig.activeViewId; + return { + ...current, + overrides: applyContentPersonalNavigationPatch( + current.overrides, + { + sidebarOrder: { + operation: "prepend", + viewId: activeViewId, + itemId: optimisticItemId, + }, + }, + ), + }; + }); + } + } + } + patchDocumentCaches(queryClient, variables.id, optimisticPatch); const renamedContentSpace = variables.title !== undefined @@ -705,7 +898,12 @@ export function useUpdateDocument() { ) : false; - return { previous, renamedContentSpace }; + return { + previous, + renamedContentSpace, + nextSidebarState, + sidebarStateKey, + }; }, onError: (_error, variables, context) => { const rollback = context as @@ -717,6 +915,36 @@ export function useUpdateDocument() { const renamedContentSpace = ( context as { renamedContentSpace?: boolean } | undefined )?.renamedContentSpace; + const nextSidebarState = ( + context as + | { + nextSidebarState?: { + version: 2; + sections: ContentSidebarSections; + }; + } + | undefined + )?.nextSidebarState; + const sidebarStateKey = ( + context as + | { + sidebarStateKey?: readonly [ + "action", + "get-content-sidebar-state", + { spaceId: string }, + ]; + } + | undefined + )?.sidebarStateKey; + const previousSidebarState = ( + context as + | { previous?: Array<[readonly unknown[], unknown]> } + | undefined + )?.previous?.find( + ([key]) => key[1] === "get-content-sidebar-state", + )?.[1] as + | { state?: { version: 2; sections: ContentSidebarSections } } + | undefined; // A CAS conflict is a normal (non-thrown) result, not a successful // save — converge the caches to the returned server document (so the // UI immediately reflects the write that actually won) but skip the @@ -750,6 +978,7 @@ export function useUpdateDocument() { serverDocument, ), ); + patchDocumentCaches(queryClient, variables.id, serverDocument); if (renamedContentSpace) { patchContentSpaceNameCaches( queryClient, @@ -770,6 +999,15 @@ export function useUpdateDocument() { void queryClient.invalidateQueries( contentDatabaseConstrainedQueryFilter(), ); + if (variables.title !== undefined) { + invalidateContentDatabaseNavigationQueries(queryClient); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-recent"], + }); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-navigation-context"], + }); + } return; } @@ -782,6 +1020,13 @@ export function useUpdateDocument() { void queryClient.invalidateQueries( contentDatabaseConstrainedQueryFilter(), ); + invalidateContentDatabaseNavigationQueries(queryClient); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-recent"], + }); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-navigation-context"], + }); } if (renamedContentSpace) { patchContentSpaceNameCaches(queryClient, variables.id, data.title); @@ -793,9 +1038,85 @@ export function useUpdateDocument() { }); } if (variables.isFavorite !== undefined) { + invalidateContentDatabaseNavigationQueries(queryClient); void queryClient.invalidateQueries({ queryKey: ["action", "get-content-database"], }); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database-personal-view"], + }); + if (nextSidebarState && sidebarStateKey) + void updateSidebarState + .mutateAsync({ + version: 2, + spaceId: sidebarStateKey[2].spaceId, + sectionsPatch: { pinned: { expanded: true } }, + }) + .then( + (saved) => queryClient.setQueryData(sidebarStateKey, saved), + () => + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-sidebar-state"], + }), + ); + if ( + variables.isFavorite === true && + previousSidebarState?.state?.sections.pinned.visible === false + ) { + toast(t("sidebar.pinned"), { + action: { + label: t("editor.properties.show"), + onClick: () => { + if (!sidebarStateKey) { + toast.error(t("sidebar.failedSaveSidebarState")); + return; + } + const current = queryClient.getQueryData<{ + state?: { + version: 2; + sections: ContentSidebarSections; + }; + }>(sidebarStateKey); + if (!current?.state) { + toast.error(t("sidebar.failedSaveSidebarState")); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-sidebar-state"], + }); + return; + } + const next = { + version: 2 as const, + spaceId: sidebarStateKey[2].spaceId, + sections: { + ...current.state.sections, + pinned: { + ...current.state.sections.pinned, + visible: true, + expanded: true, + }, + }, + }; + queryClient.setQueryData(sidebarStateKey, { state: next }); + void updateSidebarState + .mutateAsync({ + version: 2, + spaceId: sidebarStateKey[2].spaceId, + sectionsPatch: { + pinned: { visible: true, expanded: true }, + }, + }) + .then( + (saved) => + queryClient.setQueryData(sidebarStateKey, saved), + () => { + queryClient.setQueryData(sidebarStateKey, current); + toast.error(t("sidebar.failedSaveSidebarState")); + }, + ); + }, + }, + }); + } } if (data.softDeletedDatabaseIds.length > 0) { @@ -834,8 +1155,14 @@ export function useUpdateDocument() { export function useDeleteDocument() { const queryClient = useQueryClient(); return useActionMutation< - { success: boolean; deleted: number; removed?: number }, - { id: string; databaseDocumentId?: string } + { + success: boolean; + deleted: number; + removed?: number; + activeTargetDeleted?: boolean; + navigationPath?: string | null; + }, + { id: string; databaseDocumentId?: string; activeDocumentId?: string } >("delete-document", { onSuccess: (_data, variables) => { void queryClient.invalidateQueries({ @@ -854,6 +1181,7 @@ export function useDeleteDocument() { void queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient); }, }); } @@ -884,6 +1212,7 @@ export function useRestoreDocument() { void queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-content-databases"], }); + invalidateContentDatabaseNavigationQueries(queryClient); }, }); } @@ -921,6 +1250,7 @@ export function useMoveDocument() { queryKey: ["action", "list-documents"], }); void queryClient.invalidateQueries(documentQueryFilter(variables.id)); + invalidateContentDatabaseNavigationQueries(queryClient); }, }, ); @@ -1017,3 +1347,26 @@ export function filterDocumentTreeDocuments( return documents.filter((doc) => !isDatabaseContainedDocument(doc)); } +function currentDocumentSpaceId(queryClient: QueryClient, documentId: string) { + const document = queryClient + .getQueriesData(documentQueryFilter(documentId)) + .find(([, data]) => data?.id === documentId)?.[1]; + if (document?.spaceId) return document.spaceId; + const list = queryClient.getQueryData( + LIST_DOCUMENTS_QUERY_KEY, + ); + const documents = Array.isArray(list) ? list : list?.documents; + return documents?.find((candidate) => candidate.id === documentId)?.spaceId; +} + +function currentContentSidebarState( + queryClient: QueryClient, + spaceId: string | null | undefined, +) { + if (!spaceId) return undefined; + const key = ["action", "get-content-sidebar-state", { spaceId }] as const; + const data = queryClient.getQueryData<{ + state?: { version: 2; sections: ContentSidebarSections }; + }>(key); + return data?.state?.sections ? ([key, data] as const) : undefined; +} diff --git a/templates/content/app/hooks/use-local-storage.test.tsx b/templates/content/app/hooks/use-local-storage.test.tsx index 411c9bb4850..56b0c4db6ad 100644 --- a/templates/content/app/hooks/use-local-storage.test.tsx +++ b/templates/content/app/hooks/use-local-storage.test.tsx @@ -101,6 +101,71 @@ describe("useLocalStorage", () => { } }); + it("can ignore cross-tab storage events while retaining same-tab updates", () => { + const values: Record = {}; + const setters: Record void> = {}; + + function Probe({ id }: { id: string }) { + const [value, setValue] = useLocalStorage("tab-key", "personal", { + syncAcrossTabs: false, + }); + values[id] = value; + setters[id] = setValue; + return null; + } + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + <> + + + , + ); + }); + + act(() => setters.a("demo")); + expect(values).toEqual({ a: "demo", b: "demo" }); + expect(window.localStorage.getItem("tab-key")).toBe('"demo"'); + + window.localStorage.setItem("tab-key", JSON.stringify("other-tab")); + act(() => { + window.dispatchEvent( + new StorageEvent("storage", { + key: "tab-key", + newValue: JSON.stringify("other-tab"), + }), + ); + }); + expect(values).toEqual({ a: "demo", b: "demo" }); + }); + + it("syncs cross-tab storage events by default", () => { + let value = ""; + function Probe() { + [value] = useLocalStorage("default-sync-key", "personal"); + return null; + } + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render()); + + window.localStorage.setItem( + "default-sync-key", + JSON.stringify("other-tab"), + ); + act(() => { + window.dispatchEvent( + new StorageEvent("storage", { key: "default-sync-key" }), + ); + }); + expect(value).toBe("other-tab"); + }); + it("exposes the new key value during the key-transition render", () => { const renderedValues: boolean[] = []; window.localStorage.setItem("document-a", JSON.stringify(true)); diff --git a/templates/content/app/hooks/use-local-storage.ts b/templates/content/app/hooks/use-local-storage.ts index 68e2058f9d0..640f24661e1 100644 --- a/templates/content/app/hooks/use-local-storage.ts +++ b/templates/content/app/hooks/use-local-storage.ts @@ -24,6 +24,7 @@ function readStorage(key: string, defaultValue: T): T { export function useLocalStorage( key: string, defaultValue: T, + options: { syncAcrossTabs?: boolean } = {}, ): [T, (val: T | ((prev: T) => T)) => void] { const prevKeyRef = useRef(key); const [value, setValue] = useState(() => readStorage(key, defaultValue)); @@ -41,7 +42,7 @@ export function useLocalStorage( useEffect(() => { function handleStorage(event: StorageEvent) { - if (event.key === key) { + if (options.syncAcrossTabs !== false && event.key === key) { const next = readStorage(key, defaultValue); valueRef.current = next; setValue(next); @@ -70,7 +71,7 @@ export function useLocalStorage( handleLocalStorageChange, ); }; - }, [key, defaultValue]); + }, [key, defaultValue, options.syncAcrossTabs]); const set = useCallback( (val: T | ((prev: T) => T)) => { diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index bf71ba85587..ea560f6bb41 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -1047,6 +1047,9 @@ const enUS = { requestedPageUnavailable: "That page is not available to your account, so we opened Welcome.", saveFailed: "Your place could not be saved", + workspaceWelcomeUnavailableTitle: "Nothing is open here yet", + workspaceWelcomeUnavailableDescription: + "You can browse this space, but you do not have permission to create its welcome page.", }, team: { metaTitle: "Workspace access - Content", @@ -1414,8 +1417,17 @@ const enUS = { database: databaseMessages, localFiles: localFilesMessages, sidebar: { + contentSpace: "Content space", cannotReorderPages: "Cannot reorder pages", pinned: "Pinned", + recent: "Recent", + customizeSidebar: "Customize sidebar", + noRecentVisits: "No recent visits", + noPinnedItems: "No pinned items", + showMore: "Show more", + showLess: "Show less", + seeAll: "See all…", + seeAllFiles: "See all files", loadingPinned: "Loading pinned items…", dragToReorder: "Drag to reorder {{label}}", moveUp: "Move up", diff --git a/templates/content/app/i18n/ar-SA.ts b/templates/content/app/i18n/ar-SA.ts index 261dfcdffbb..c0bdd855a80 100644 --- a/templates/content/app/i18n/ar-SA.ts +++ b/templates/content/app/i18n/ar-SA.ts @@ -833,6 +833,7 @@ const rawLiterals = { }, }, sidebar: { + contentSpace: "مساحة Content", addChild: "إضافة عنصر فرعي", addChildTo: "إضافة عنصر فرعي إلى {{title}}", database: "المجموعة", @@ -871,6 +872,9 @@ const landing = { requestedPageUnavailable: "هذه الصفحة غير متاحة لحسابك، لذلك فتحنا صفحة الترحيب.", saveFailed: "تعذر حفظ موضعك", + workspaceWelcomeUnavailableTitle: "لا يوجد شيء مفتوح هنا بعد", + workspaceWelcomeUnavailableDescription: + "يمكنك عرض مساحة العمل هذه، لكن ليس لديك إذن لإنشاء صفحة الترحيب الخاصة بها.", }; const comments = { @@ -943,6 +947,14 @@ const reference = { }; const sidebarPinned = { + recent: "الأخيرة", + customizeSidebar: "تخصيص الشريط الجانبي", + noRecentVisits: "لا توجد زيارات حديثة", + noPinnedItems: "لا توجد عناصر مثبتة", + showMore: "عرض المزيد", + showLess: "عرض أقل", + seeAll: "عرض الكل…", + seeAllFiles: "عرض كل الملفات", pinned: "مثبتة", loadingPinned: "جارٍ تحميل العناصر المثبتة…", dragToReorder: "اسحب لإعادة ترتيب {{label}}", diff --git a/templates/content/app/i18n/de-DE.ts b/templates/content/app/i18n/de-DE.ts index c9f989028bb..238112a2470 100644 --- a/templates/content/app/i18n/de-DE.ts +++ b/templates/content/app/i18n/de-DE.ts @@ -872,6 +872,7 @@ const rawLiterals = { }, }, sidebar: { + contentSpace: "Content-Bereich", addChild: "Unterelement hinzufügen", addChildTo: "Unterelement zu {{title}} hinzufügen", database: "Sammlung", @@ -911,6 +912,9 @@ const landing = { requestedPageUnavailable: "Diese Seite ist für dein Konto nicht verfügbar. Daher wurde die Willkommensseite geöffnet.", saveFailed: "Deine Position konnte nicht gespeichert werden", + workspaceWelcomeUnavailableTitle: "Hier ist noch nichts geöffnet", + workspaceWelcomeUnavailableDescription: + "Du kannst diesen Arbeitsbereich ansehen, hast aber keine Berechtigung, seine Willkommensseite zu erstellen.", }; const comments = { @@ -979,6 +983,14 @@ const reference = { }; const sidebarPinned = { + recent: "Zuletzt besucht", + customizeSidebar: "Seitenleiste anpassen", + noRecentVisits: "Keine letzten Besuche", + noPinnedItems: "Keine angehefteten Elemente", + showMore: "Mehr anzeigen", + showLess: "Weniger anzeigen", + seeAll: "Alle anzeigen…", + seeAllFiles: "Alle Dateien anzeigen", pinned: "Angeheftet", loadingPinned: "Angeheftete Elemente werden geladen…", dragToReorder: "{{label}} zum Neuordnen ziehen", diff --git a/templates/content/app/i18n/es-ES.ts b/templates/content/app/i18n/es-ES.ts index 6892cb9ba24..d10fddd82b1 100644 --- a/templates/content/app/i18n/es-ES.ts +++ b/templates/content/app/i18n/es-ES.ts @@ -1077,6 +1077,7 @@ const rawLiterals = { }, }, sidebar: { + contentSpace: "Espacio de Content", addChild: "Agregar hijo", addChildTo: "Agregar hijo a {{title}}", completeStepsAboveFirst: "Completa primero los pasos anteriores", @@ -1158,6 +1159,9 @@ const landing = { requestedPageUnavailable: "Esa página no está disponible para tu cuenta, así que abrimos la página de bienvenida.", saveFailed: "No se pudo guardar tu ubicación", + workspaceWelcomeUnavailableTitle: "Todavía no hay nada abierto aquí", + workspaceWelcomeUnavailableDescription: + "Puedes ver este espacio de trabajo, pero no tienes permiso para crear su página de bienvenida.", }; const comments = { @@ -1227,6 +1231,14 @@ const reference = { }; const sidebarPinned = { + recent: "Recientes", + customizeSidebar: "Personalizar barra lateral", + noRecentVisits: "No hay visitas recientes", + noPinnedItems: "No hay elementos fijados", + showMore: "Mostrar más", + showLess: "Mostrar menos", + seeAll: "Ver todo…", + seeAllFiles: "Ver todos los archivos", pinned: "Fijados", loadingPinned: "Cargando elementos fijados…", dragToReorder: "Arrastra para reordenar {{label}}", diff --git a/templates/content/app/i18n/fr-FR.ts b/templates/content/app/i18n/fr-FR.ts index 8db42ac2778..925643f86f4 100644 --- a/templates/content/app/i18n/fr-FR.ts +++ b/templates/content/app/i18n/fr-FR.ts @@ -869,6 +869,7 @@ const rawLiterals = { }, }, sidebar: { + contentSpace: "Espace Content", addChild: "Ajouter un enfant", addChildTo: "Ajouter un enfant à {{title}}", database: "Collection", @@ -910,6 +911,9 @@ const landing = { requestedPageUnavailable: "Cette page n’est pas accessible à votre compte. Nous avons donc ouvert la page de bienvenue.", saveFailed: "Votre emplacement n’a pas pu être enregistré", + workspaceWelcomeUnavailableTitle: "Rien n’est encore ouvert ici", + workspaceWelcomeUnavailableDescription: + "Vous pouvez consulter cet espace de travail, mais vous n’êtes pas autorisé à créer sa page d’accueil.", }; const comments = { @@ -979,6 +983,14 @@ const reference = { }; const sidebarPinned = { + recent: "Récents", + customizeSidebar: "Personnaliser la barre latérale", + noRecentVisits: "Aucune visite récente", + noPinnedItems: "Aucun élément épinglé", + showMore: "Afficher plus", + showLess: "Afficher moins", + seeAll: "Tout afficher…", + seeAllFiles: "Afficher tous les fichiers", pinned: "Épinglés", loadingPinned: "Chargement des éléments épinglés…", dragToReorder: "Faites glisser {{label}} pour le réorganiser", diff --git a/templates/content/app/i18n/hi-IN.ts b/templates/content/app/i18n/hi-IN.ts index 8ff1fc9c42b..3f17bf295bd 100644 --- a/templates/content/app/i18n/hi-IN.ts +++ b/templates/content/app/i18n/hi-IN.ts @@ -832,6 +832,7 @@ const rawLiterals = { }, }, sidebar: { + contentSpace: "Content स्पेस", addChild: "चाइल्ड जोड़ें", addChildTo: "{{title}} में चाइल्ड जोड़ें", database: "संग्रह", @@ -869,6 +870,9 @@ const landing = { requestedPageUnavailable: "वह पेज आपके खाते के लिए उपलब्ध नहीं है, इसलिए हमने स्वागत पेज खोल दिया है।", saveFailed: "आपकी जगह सेव नहीं की जा सकी", + workspaceWelcomeUnavailableTitle: "यहाँ अभी कुछ भी खुला नहीं है", + workspaceWelcomeUnavailableDescription: + "आप यह वर्कस्पेस देख सकते हैं, लेकिन इसका स्वागत पेज बनाने की अनुमति आपके पास नहीं है।", }; const comments = { @@ -937,6 +941,14 @@ const reference = { }; const sidebarPinned = { + recent: "हाल ही में देखे गए", + customizeSidebar: "साइडबार अनुकूलित करें", + noRecentVisits: "हाल ही में कोई पेज नहीं देखा", + noPinnedItems: "कोई पिन किया गया आइटम नहीं", + showMore: "और दिखाएँ", + showLess: "कम दिखाएँ", + seeAll: "सभी देखें…", + seeAllFiles: "सभी फ़ाइलें देखें", pinned: "पिन किए गए", loadingPinned: "पिन किए गए आइटम लोड हो रहे हैं…", dragToReorder: "{{label}} को फिर से क्रमबद्ध करने के लिए खींचें", diff --git a/templates/content/app/i18n/ja-JP.ts b/templates/content/app/i18n/ja-JP.ts index 6e36f8adb4f..48e8c675796 100644 --- a/templates/content/app/i18n/ja-JP.ts +++ b/templates/content/app/i18n/ja-JP.ts @@ -847,6 +847,7 @@ const rawLiterals = { }, }, sidebar: { + contentSpace: "Content スペース", addChild: "子項目を追加", addChildTo: "{{title}} に子項目を追加", database: "コレクション", @@ -884,6 +885,9 @@ const landing = { requestedPageUnavailable: "そのページはお使いのアカウントでは利用できないため、ようこそページを開きました。", saveFailed: "現在位置を保存できませんでした", + workspaceWelcomeUnavailableTitle: "まだ何も開かれていません", + workspaceWelcomeUnavailableDescription: + "このワークスペースは表示できますが、ようこそページを作成する権限がありません。", }; const comments = { @@ -951,6 +955,14 @@ const reference = { }; const sidebarPinned = { + recent: "最近の閲覧", + customizeSidebar: "サイドバーをカスタマイズ", + noRecentVisits: "最近の閲覧はありません", + noPinnedItems: "固定された項目はありません", + showMore: "もっと見る", + showLess: "表示を減らす", + seeAll: "すべて表示…", + seeAllFiles: "すべてのファイルを表示", pinned: "固定済み", loadingPinned: "固定済みの項目を読み込み中…", dragToReorder: "{{label}} をドラッグして並べ替え", diff --git a/templates/content/app/i18n/ko-KR.ts b/templates/content/app/i18n/ko-KR.ts index 5802392701f..86b0506e665 100644 --- a/templates/content/app/i18n/ko-KR.ts +++ b/templates/content/app/i18n/ko-KR.ts @@ -841,6 +841,7 @@ const rawLiterals = { }, }, sidebar: { + contentSpace: "Content 공간", addChild: "하위 항목 추가", addChildTo: "{{title}}에 하위 항목 추가", database: "컬렉션", @@ -878,6 +879,9 @@ const landing = { requestedPageUnavailable: "해당 페이지는 이 계정에서 사용할 수 없어 시작 페이지를 열었습니다.", saveFailed: "현재 위치를 저장하지 못했습니다", + workspaceWelcomeUnavailableTitle: "아직 열린 콘텐츠가 없습니다", + workspaceWelcomeUnavailableDescription: + "이 워크스페이스를 볼 수 있지만 시작 페이지를 만들 권한이 없습니다.", }; const comments = { @@ -945,6 +949,14 @@ const reference = { }; const sidebarPinned = { + recent: "최근 방문", + customizeSidebar: "사이드바 사용자 지정", + noRecentVisits: "최근 방문 기록 없음", + noPinnedItems: "고정된 항목 없음", + showMore: "더 보기", + showLess: "간략히 보기", + seeAll: "모두 보기…", + seeAllFiles: "모든 파일 보기", pinned: "고정됨", loadingPinned: "고정된 항목 불러오는 중…", dragToReorder: "{{label}} 드래그하여 순서 변경", diff --git a/templates/content/app/i18n/pt-BR.ts b/templates/content/app/i18n/pt-BR.ts index 43523a0bcdd..8d1c994fbcd 100644 --- a/templates/content/app/i18n/pt-BR.ts +++ b/templates/content/app/i18n/pt-BR.ts @@ -864,6 +864,7 @@ const rawLiterals = { }, }, sidebar: { + contentSpace: "Espaço do Content", addChild: "Adicionar filho", addChildTo: "Adicionar filho a {{title}}", database: "Coleção", @@ -901,6 +902,9 @@ const landing = { requestedPageUnavailable: "Essa página não está disponível para sua conta, então abrimos a página de boas-vindas.", saveFailed: "Não foi possível salvar sua localização", + workspaceWelcomeUnavailableTitle: "Ainda não há nada aberto aqui", + workspaceWelcomeUnavailableDescription: + "Você pode ver este espaço de trabalho, mas não tem permissão para criar a página de boas-vindas.", }; const comments = { @@ -970,6 +974,14 @@ const reference = { }; const sidebarPinned = { + recent: "Recentes", + customizeSidebar: "Personalizar barra lateral", + noRecentVisits: "Nenhuma visita recente", + noPinnedItems: "Nenhum item fixado", + showMore: "Mostrar mais", + showLess: "Mostrar menos", + seeAll: "Ver tudo…", + seeAllFiles: "Ver todos os arquivos", pinned: "Fixados", loadingPinned: "Carregando itens fixados…", dragToReorder: "Arraste para reordenar {{label}}", diff --git a/templates/content/app/i18n/zh-CN.ts b/templates/content/app/i18n/zh-CN.ts index 70fcdf95917..6be90e66e86 100644 --- a/templates/content/app/i18n/zh-CN.ts +++ b/templates/content/app/i18n/zh-CN.ts @@ -898,6 +898,7 @@ const rawLiterals = { }, }, sidebar: { + contentSpace: "Content 空间", addChild: "新增子项", addChildTo: "新增子项至 {{title}}", completeStepsAboveFirst: "请先完成上面的步骤", @@ -969,6 +970,9 @@ const landing = { previousPageUnavailable: "您之前的页面已不可用,因此我们打开了欢迎页面。", requestedPageUnavailable: "该页面对你的账户不可用,因此我们打开了欢迎页面。", saveFailed: "无法保存您的位置", + workspaceWelcomeUnavailableTitle: "此处尚未打开任何内容", + workspaceWelcomeUnavailableDescription: + "您可以查看此工作区,但没有创建欢迎页面的权限。", }; const comments = { @@ -1035,6 +1039,14 @@ const reference = { }; const sidebarPinned = { + recent: "最近访问", + customizeSidebar: "自定义侧边栏", + noRecentVisits: "暂无最近访问记录", + noPinnedItems: "暂无已固定项目", + showMore: "显示更多", + showLess: "显示更少", + seeAll: "查看全部…", + seeAllFiles: "查看所有文件", pinned: "已固定", loadingPinned: "正在加载已固定项目…", dragToReorder: "拖动以重新排序 {{label}}", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index d8388653be0..b9aa925e728 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -238,6 +238,9 @@ const overrides = { requestedPageUnavailable: "該頁面對你的帳戶不可用,因此我們開啟了歡迎頁面。", saveFailed: "無法儲存您的位置", + workspaceWelcomeUnavailableTitle: "這裡尚未開啟任何內容", + workspaceWelcomeUnavailableDescription: + "您可以瀏覽此空間,但沒有建立歡迎頁面的權限。", }, team: { metaTitle: "工作區存取 - Content", @@ -1525,8 +1528,17 @@ const overrides = { sidebar: "側邊欄", }, sidebar: { + contentSpace: "Content 空間", cannotReorderPages: "無法重新排序頁面", pinned: "已釘選", + recent: "最近瀏覽", + customizeSidebar: "自訂側邊欄", + noRecentVisits: "尚無最近瀏覽記錄", + noPinnedItems: "尚無已釘選項目", + showMore: "顯示更多", + showLess: "顯示更少", + seeAll: "查看全部…", + seeAllFiles: "查看所有檔案", loadingPinned: "正在載入已釘選項目…", dragToReorder: "拖曳以重新排序 {{label}}", moveUp: "上移", @@ -1645,7 +1657,7 @@ const overrides = { page: "頁面", private: "私人", results: "結果", - search: "搜尋", + search: "搜尋...", searchPages: "搜尋頁面...", refreshConnection: "重新整理連線", restoreDatabase: "還原", diff --git a/templates/content/app/lib/content-command-menu.test.ts b/templates/content/app/lib/content-command-menu.test.ts new file mode 100644 index 00000000000..6b6eec9d885 --- /dev/null +++ b/templates/content/app/lib/content-command-menu.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from "vitest"; + +import { + CONTENT_COMMAND_MENU_OPEN_EVENT, + openContentCommandMenu, +} from "./content-command-menu"; + +describe("openContentCommandMenu", () => { + it("carries the visible launcher that should regain focus", () => { + const launcher = document.createElement("button"); + const listener = vi.fn(); + window.addEventListener(CONTENT_COMMAND_MENU_OPEN_EVENT, listener); + + openContentCommandMenu(launcher); + + expect(listener).toHaveBeenCalledOnce(); + expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual({ + returnFocusTo: launcher, + }); + window.removeEventListener(CONTENT_COMMAND_MENU_OPEN_EVENT, listener); + }); +}); diff --git a/templates/content/app/lib/content-command-menu.ts b/templates/content/app/lib/content-command-menu.ts new file mode 100644 index 00000000000..6f02df4445c --- /dev/null +++ b/templates/content/app/lib/content-command-menu.ts @@ -0,0 +1,9 @@ +export const CONTENT_COMMAND_MENU_OPEN_EVENT = "content:open-command-menu"; + +export function openContentCommandMenu(returnFocusTo?: HTMLElement) { + window.dispatchEvent( + new CustomEvent(CONTENT_COMMAND_MENU_OPEN_EVENT, { + detail: { returnFocusTo }, + }), + ); +} diff --git a/templates/content/app/lib/content-landing.test.ts b/templates/content/app/lib/content-landing.test.ts index 42b46cd06ab..c27aa84c1ea 100644 --- a/templates/content/app/lib/content-landing.test.ts +++ b/templates/content/app/lib/content-landing.test.ts @@ -22,7 +22,7 @@ describe("rememberContentLandingDocument", () => { it("stores the successfully loaded page separately from agent navigation", async () => { writeClientAppState.mockResolvedValue({ documentId: "doc-1" }); - await rememberContentLandingDocument("doc-1"); + await rememberContentLandingDocument({ documentId: "doc-1" }); expect(writeClientAppState).toHaveBeenCalledWith( "content-last-location-v1", @@ -66,8 +66,8 @@ describe("rememberContentLandingDocument", () => { ) .mockResolvedValueOnce({ documentId: "doc-2" }); - const first = rememberContentLandingDocument("doc-1"); - const second = rememberContentLandingDocument("doc-2"); + const first = rememberContentLandingDocument({ documentId: "doc-1" }); + const second = rememberContentLandingDocument({ documentId: "doc-2" }); await vi.waitFor(() => expect(writeClientAppState).toHaveBeenCalledTimes(1), ); @@ -84,8 +84,23 @@ describe("rememberContentLandingDocument", () => { it("leaves write failures observable to the caller", async () => { writeClientAppState.mockRejectedValue(new Error("state unavailable")); - await expect(rememberContentLandingDocument("doc-1")).rejects.toThrow( - "state unavailable", + await expect( + rememberContentLandingDocument({ documentId: "doc-1" }), + ).rejects.toThrow("state unavailable"); + }); + + it("stores exact destinations separately for each Content space", async () => { + writeClientAppState.mockResolvedValue({ documentId: "doc-1" }); + + await rememberContentLandingDocument( + { documentId: "doc-1", databaseId: "db-1", viewId: "view-1" }, + "space-1", + ); + + expect(writeClientAppState).toHaveBeenCalledWith( + "content-last-location-v2:space-1", + { documentId: "doc-1", databaseId: "db-1", viewId: "view-1" }, + { requestSource: "content-landing" }, ); }); }); diff --git a/templates/content/app/lib/content-landing.ts b/templates/content/app/lib/content-landing.ts index 65e2b6d1e1a..27a4af184f2 100644 --- a/templates/content/app/lib/content-landing.ts +++ b/templates/content/app/lib/content-landing.ts @@ -1,6 +1,7 @@ import { writeClientAppState } from "@agent-native/core/client/application-state"; import { CONTENT_LAST_LOCATION_STATE_KEY, + contentSpaceLastLocationStateKey, type ContentLastLocationState, } from "@shared/content-landing"; @@ -48,23 +49,40 @@ export function readContentLandingRecovery( let landingWriteQueue = Promise.resolve(); +export function rememberContentLandingDocument( + target: ContentLastLocationState, + spaceId?: string, +): Promise; export function rememberContentLandingDocument( documentId: string, title?: string, +): Promise; +export function rememberContentLandingDocument( + targetOrDocumentId: ContentLastLocationState | string, + spaceIdOrTitle?: string, ) { - const value: ContentLastLocationState = title?.trim() - ? { documentId, title } - : { documentId }; + const target: ContentLastLocationState = + typeof targetOrDocumentId === "string" + ? { + documentId: targetOrDocumentId, + ...(spaceIdOrTitle?.trim() ? { title: spaceIdOrTitle } : {}), + } + : targetOrDocumentId; + const spaceId = + typeof targetOrDocumentId === "string" ? undefined : spaceIdOrTitle; const write = landingWriteQueue.then(() => writeClientAppState( - CONTENT_LAST_LOCATION_STATE_KEY, - value, + spaceId + ? contentSpaceLastLocationStateKey(spaceId) + : CONTENT_LAST_LOCATION_STATE_KEY, + target, { requestSource: "content-landing" }, ), ); - landingWriteQueue = write.then( + const result = write.then(() => undefined); + landingWriteQueue = result.then( () => undefined, () => undefined, ); - return write; + return result; } diff --git a/templates/content/app/root.tsx b/templates/content/app/root.tsx index d7ff10ea6ba..592d4d91f3a 100644 --- a/templates/content/app/root.tsx +++ b/templates/content/app/root.tsx @@ -67,6 +67,7 @@ import { LocalFolderLiveSync } from "./components/LocalFolderLiveSync"; import { useDbSync } from "./hooks/use-db-sync"; import { useNavigationState } from "./hooks/use-navigation-state"; import { i18nCatalog } from "./i18n"; +import { CONTENT_COMMAND_MENU_OPEN_EVENT } from "./lib/content-command-menu"; import stylesheet from "./global.css?url"; import katexStylesheet from "katex/dist/katex.min.css?url"; @@ -323,9 +324,11 @@ function PublicAgentShell({ children }: { children: React.ReactNode }) { function ContentCommandMenu({ open, onOpenChange, + onCloseAutoFocus, }: { open: boolean; onOpenChange: (open: boolean) => void; + onCloseAutoFocus: (event: Event) => void; }) { const t = useT(); const navigate = useNavigate(); @@ -333,6 +336,7 @@ function ContentCommandMenu({ { - if (cmdkOpen || !commandTrigger.current) return; - const trigger = commandTrigger.current; + const handleOpen = (event: Event) => { + commandTrigger.current = + (event as CustomEvent<{ returnFocusTo?: HTMLElement }>).detail + ?.returnFocusTo ?? + (document.activeElement instanceof HTMLElement + ? document.activeElement + : null); + setCmdkOpen(true); + }; + window.addEventListener(CONTENT_COMMAND_MENU_OPEN_EVENT, handleOpen); + return () => + window.removeEventListener(CONTENT_COMMAND_MENU_OPEN_EVENT, handleOpen); + }, []); + const handleCommandMenuCloseAutoFocus = useCallback((event: Event) => { + const target = commandTrigger.current; + if (!target?.isConnected) return; + event.preventDefault(); commandTrigger.current = null; - const frame = window.requestAnimationFrame(() => { - if (trigger.isConnected) trigger.focus(); - }); - return () => window.cancelAnimationFrame(frame); - }, [cmdkOpen]); + target.focus(); + }, []); // Public document paths (/p/*) SSR real content without the ClientOnly gate // so crawlers and unauthenticated visitors receive full markup on first visit. @@ -454,7 +470,11 @@ export default function Root() { - + diff --git a/templates/content/app/routes/_app.favorites.tsx b/templates/content/app/routes/_app.favorites.tsx index 2f5489f2243..b98b779df85 100644 --- a/templates/content/app/routes/_app.favorites.tsx +++ b/templates/content/app/routes/_app.favorites.tsx @@ -1,17 +1,109 @@ -import { Navigate } from "react-router"; +import { useT } from "@agent-native/core/client/i18n"; +import type { ContentSidebarViewOrder } from "@shared/api"; +import { + contentRecentHref, + contentRecentTargetKey, +} from "@shared/content-personal-navigation"; +import { Link, Navigate, useSearchParams } from "react-router"; +import { contentSidebarOrderedItems } from "@/components/editor/database/sidebar"; import { QueryErrorState } from "@/components/QueryErrorState"; import { Skeleton } from "@/components/ui/skeleton"; +import { + isContentDatabaseUnavailable, + useContentDatabaseById, + useContentDatabasePersonalView, +} from "@/hooks/use-content-database"; +import { useContentRecent } from "@/hooks/use-content-recent"; import { useContentSpaces } from "@/hooks/use-content-spaces"; +function RecentCollection({ spaceId }: { spaceId: string }) { + const t = useT(); + const recent = useContentRecent(spaceId); + + if (recent.isError) { + return ( + void recent.refetch()} + retrying={recent.isFetching} + /> + ); + } + + return ( +
+
+

{t("sidebar.recent")}

+ {recent.isLoading ? ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ ) : recent.data?.entries.length ? ( +
+ {recent.data.entries.map((entry) => ( + + + {entry.icon ? `${entry.icon} ` : ""} + {entry.title || t("sidebar.untitled")} + + + {entry.viewName ?? ""} + + + ))} +
+ ) : ( +

+ {t("sidebar.noRecentVisits")} +

+ )} +
+
+ ); +} + export default function FavoritesRoute() { + const t = useT(); + const [searchParams] = useSearchParams(); + const spaceId = searchParams.get("spaceId"); + const showingRecent = searchParams.get("view") === "recent"; const contentSpacesQuery = useContentSpaces(); + const databaseId = contentSpacesQuery.data?.favoritesDatabaseId ?? null; + const favorites = useContentDatabaseById(databaseId, { + enabled: Boolean(spaceId), + limit: 50, + contentSpaceId: spaceId ?? undefined, + }); + const personalView = useContentDatabasePersonalView( + spaceId && !showingRecent ? databaseId : null, + ); + + if (spaceId && showingRecent) { + return ; + } - if (contentSpacesQuery.isError) { + if ( + contentSpacesQuery.isError || + (spaceId && (favorites.isError || personalView.isError)) + ) { return ( void contentSpacesQuery.refetch()} - retrying={contentSpacesQuery.isFetching} + onRetry={() => { + void contentSpacesQuery.refetch(); + void favorites.refetch(); + void personalView.refetch(); + }} + retrying={ + contentSpacesQuery.isFetching || + favorites.isFetching || + personalView.isFetching + } /> ); } @@ -26,5 +118,62 @@ export default function FavoritesRoute() { ); } - return ; + if (!spaceId) return ; + + const data = isContentDatabaseUnavailable(favorites.data) + ? undefined + : favorites.data; + const savedActiveViewId = + data?.database.viewConfig.activeViewId ?? + data?.database.viewConfig.views[0]?.id ?? + "default"; + const activeViewId = + personalView.data?.overrides?.activeViewId && + data?.database.viewConfig.views.some( + (view) => view.id === personalView.data?.overrides?.activeViewId, + ) + ? personalView.data.overrides.activeViewId + : savedActiveViewId; + const order = + personalView.data?.overrides?.views.find((view) => view.id === activeViewId) + ?.sidebarOrder ?? + ({ + mode: "custom", + itemIds: data?.items.map((item) => item.id) ?? [], + } satisfies ContentSidebarViewOrder); + const items = data ? contentSidebarOrderedItems(data.items, order) : []; + + return ( +
+
+

{t("sidebar.pinned")}

+ {favorites.isLoading || personalView.isLoading ? ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ ) : items.length ? ( +
+ {items.map((item) => ( + + + {item.document.icon ? `${item.document.icon} ` : ""} + {item.document.title || t("sidebar.untitled")} + + + ))} +
+ ) : ( +

+ {t("sidebar.noPinnedItems")} +

+ )} +
+
+ ); } diff --git a/templates/content/app/routes/_app.home.test.tsx b/templates/content/app/routes/_app.home.test.tsx index 3e1492ddbea..ff813084cea 100644 --- a/templates/content/app/routes/_app.home.test.tsx +++ b/templates/content/app/routes/_app.home.test.tsx @@ -4,17 +4,20 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { resolveLanding, useLastLocationTitleHint } = vi.hoisted(() => ({ - resolveLanding: { - mutateAsync: vi.fn(), - isError: false, - isPending: false, - reset: vi.fn(), - }, - useLastLocationTitleHint: vi.fn( - () => null as null | { documentId: string; title: string }, - ), -})); +const { resolveLanding, searchParams, useLastLocationTitleHint } = vi.hoisted( + () => ({ + resolveLanding: { + mutateAsync: vi.fn(), + isError: false, + isPending: false, + reset: vi.fn(), + }, + searchParams: new URLSearchParams(), + useLastLocationTitleHint: vi.fn( + () => null as null | { documentId: string; title: string }, + ), + }), +); vi.mock("@agent-native/core/client/hooks", () => ({ useActionMutation: () => resolveLanding, @@ -37,6 +40,7 @@ const navigate = vi.fn(); vi.mock("react-router", () => ({ useLocation: () => ({ pathname: "/home", search: "", hash: "" }), useNavigate: () => navigate, + useSearchParams: () => [searchParams], })); import { @@ -59,6 +63,7 @@ describe("home landing route optimistic title", () => { beforeEach(() => { resolveLanding.mutateAsync.mockReset(); resolveLanding.isError = false; + searchParams.delete("spaceId"); useLastLocationTitleHint.mockReturnValue(null); navigate.mockReset(); stashLandingTitleHint(null); @@ -136,4 +141,79 @@ describe("home landing route optimistic title", () => { expect(peekLandingTitleHint("welcome-1")).toBeNull(); expect(peekLandingTitleHint("doc-1")).toBeNull(); }); + + it("resolves an explicit workspace and opens its exact saved target", async () => { + searchParams.set("spaceId", "space-2"); + resolveLanding.mutateAsync.mockResolvedValue({ + target: { + documentId: "doc-2", + databaseId: "database-2", + viewId: "board", + }, + resolution: "restored", + }); + + renderHome(root); + await act(async () => { + await Promise.resolve(); + }); + + expect(resolveLanding.mutateAsync).toHaveBeenCalledWith({ + spaceId: "space-2", + }); + expect(navigate).toHaveBeenCalledWith( + "/page/doc-2?databaseId=database-2&viewId=board", + { replace: true }, + ); + }); + + it("ignores a stale landing resolution after switching workspaces", async () => { + const pending = new Map< + string, + (value: { + target: { documentId: string; databaseId: string; viewId: string }; + resolution: "restored"; + }) => void + >(); + resolveLanding.mutateAsync.mockImplementation( + ({ spaceId }: { spaceId: string }) => + new Promise((resolve) => pending.set(spaceId, resolve)), + ); + + searchParams.set("spaceId", "space-a"); + renderHome(root); + await act(async () => Promise.resolve()); + searchParams.set("spaceId", "space-b"); + renderHome(root); + await act(async () => Promise.resolve()); + + await act(async () => { + pending.get("space-b")?.({ + target: { + documentId: "doc-b", + databaseId: "database-b", + viewId: "board", + }, + resolution: "restored", + }); + await Promise.resolve(); + }); + await act(async () => { + pending.get("space-a")?.({ + target: { + documentId: "doc-a", + databaseId: "database-a", + viewId: "table", + }, + resolution: "restored", + }); + await Promise.resolve(); + }); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith( + "/page/doc-b?databaseId=database-b&viewId=board", + { replace: true }, + ); + }); }); diff --git a/templates/content/app/routes/_app.home.tsx b/templates/content/app/routes/_app.home.tsx index 8ae0798289a..3eaa5b24893 100644 --- a/templates/content/app/routes/_app.home.tsx +++ b/templates/content/app/routes/_app.home.tsx @@ -1,12 +1,18 @@ import { useActionMutation } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; -import type { ContentLandingResult } from "@shared/content-landing"; +import type { + ContentLandingResult, + ContentSpaceLandingResult, +} from "@shared/content-landing"; +import { contentRecentHref } from "@shared/content-personal-navigation"; import { useCallback, useEffect, useRef } from "react"; -import { useLocation, useNavigate } from "react-router"; +import { Link, useLocation, useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { QueryErrorState } from "@/components/QueryErrorState"; +import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { useContentSpaces } from "@/hooks/use-content-spaces"; import { useLastLocationTitleHint } from "@/hooks/use-optimistic-document-title"; import { readContentLandingRecovery } from "@/lib/content-landing"; import { @@ -58,26 +64,79 @@ function DocumentSkeleton({ title }: { title?: string | null }) { ); } +function WorkspaceWelcomeUnavailable({ spaceId }: { spaceId: string }) { + const t = useT(); + const spaces = useContentSpaces(); + + if (spaces.isError) { + return ( + void spaces.refetch()} + retrying={spaces.isFetching} + /> + ); + } + + const space = spaces.data?.spaces.find( + (candidate) => candidate.id === spaceId, + ); + return ( +
+
+

+ {t("landing.workspaceWelcomeUnavailableTitle")} +

+

+ {t("landing.workspaceWelcomeUnavailableDescription")} +

+ {space ? ( + + ) : null} +
+
+ ); +} + export default function HomeRoute() { const t = useT(); const location = useLocation(); const navigate = useNavigate(); - const startedRef = useRef(false); + const [searchParams] = useSearchParams(); + const spaceId = searchParams.get("spaceId"); + const startedFor = useRef(null); + const landingRequestIdRef = useRef(0); const lastLocationHint = useLastLocationTitleHint(); const lastLocationHintRef = useRef(lastLocationHint); lastLocationHintRef.current = lastLocationHint; const recoveredDocumentId = readContentLandingRecovery(location.state)?.unavailableDocumentId ?? null; const resolveLanding = useActionMutation< - ContentLandingResult, - Record + ContentLandingResult | ContentSpaceLandingResult, + { spaceId?: string } >("resolve-content-landing"); const openLanding = useCallback(async () => { - if (startedRef.current) return; - startedRef.current = true; + const requestKey = spaceId ?? "personal"; + if (startedFor.current === requestKey) return; + startedFor.current = requestKey; + const requestId = ++landingRequestIdRef.current; try { - const result = await resolveLanding.mutateAsync({}); + const result = await resolveLanding.mutateAsync( + spaceId ? { spaceId } : {}, + ); + if (requestId !== landingRequestIdRef.current) return; + if ("target" in result) { + if (!result.target) return; + if (result.fallbackReason === "saved-document-unavailable") { + toast.info(t("landing.previousPageUnavailable")); + } + void navigate(contentRecentHref(result.target), { replace: true }); + return; + } if (recoveredDocumentId) { toast.info(t("landing.requestedPageUnavailable")); } else if (result.fallbackReason === "saved-document-unavailable") { @@ -108,6 +167,7 @@ export default function HomeRoute() { navigate, recoveredDocumentId, resolveLanding, + spaceId, t, ]); @@ -120,13 +180,21 @@ export default function HomeRoute() { { resolveLanding.reset(); - startedRef.current = false; + startedFor.current = null; void openLanding(); }} retrying={resolveLanding.isPending} /> ); } + if ( + spaceId && + resolveLanding.data && + "target" in resolveLanding.data && + resolveLanding.data.resolution === "welcome-unavailable" + ) { + return ; + } return ( ); diff --git a/templates/content/app/routes/_app.page.$id.test.tsx b/templates/content/app/routes/_app.page.$id.test.tsx index 12b4bc95115..f6934ee4a5e 100644 --- a/templates/content/app/routes/_app.page.$id.test.tsx +++ b/templates/content/app/routes/_app.page.$id.test.tsx @@ -53,6 +53,7 @@ describe("document page route context", () => { databaseId: "database-1", databaseDocumentId: "database-page", viewId: "ready-view", + foreground: true, }); }); }); diff --git a/templates/content/app/routes/_app.page.$id.tsx b/templates/content/app/routes/_app.page.$id.tsx index d3b3089b24c..3535344d8e3 100644 --- a/templates/content/app/routes/_app.page.$id.tsx +++ b/templates/content/app/routes/_app.page.$id.tsx @@ -27,6 +27,7 @@ export default function DocumentPage() { return id ? (