From 3ff70a4b6d61c6d711129b4717fef5287f7fd480 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Sat, 27 Sep 2025 23:39:57 +0200 Subject: [PATCH 1/4] feat(context): delete action --- src/app/src/App.vue | 6 +- .../components/modal/ModalConfirmAction.vue | 7 +- src/app/src/composables/useContext.ts | 10 +- src/app/src/composables/useDraftDocuments.ts | 78 +++++---- src/app/src/composables/useDraftMedias.ts | 48 +++--- src/app/src/composables/useStudio.ts | 4 +- src/app/src/composables/useTree.ts | 14 +- src/app/src/utils/context.ts | 1 + src/app/src/utils/draft.ts | 2 +- src/app/src/utils/tree.ts | 93 ++++++++++- src/app/test/mocks/tree.ts | 12 +- src/app/test/utils/tree.test.ts | 150 +++++++++++++++++- 12 files changed, 339 insertions(+), 86 deletions(-) diff --git a/src/app/src/App.vue b/src/app/src/App.vue index d286d614..9be2ef52 100644 --- a/src/app/src/App.vue +++ b/src/app/src/App.vue @@ -23,8 +23,8 @@ function detectActiveDocuments() { }) } -function onContentSelect(id: string) { - documentTree.selectItemById(id) +async function onContentSelect(id: string) { + await documentTree.selectItemById(id) ui.openPanel(StudioFeature.Content) } @@ -70,7 +70,7 @@ host.on.mounted(() => { size="lg" variant="outline" label="Edit This Page" - class="shadow-lg" + class="shadow-lg bg-white hover:bg-gray-100" @click="onContentSelect(activeDocuments[0].id)" /> diff --git a/src/app/src/components/modal/ModalConfirmAction.vue b/src/app/src/components/modal/ModalConfirmAction.vue index e265df51..3d57d11b 100644 --- a/src/app/src/components/modal/ModalConfirmAction.vue +++ b/src/app/src/components/modal/ModalConfirmAction.vue @@ -27,22 +27,27 @@ const emit = defineEmits<{ close: [] }>() const titleMap = { [StudioItemActionId.RevertItem]: `Reverting ${name.value}`, + [StudioItemActionId.DeleteItem]: `Deleting ${name.value}`, } as Record const descriptionMap = { [StudioItemActionId.RevertItem]: `Are you sure you want to revert ${name.value} back to its original version?`, + [StudioItemActionId.DeleteItem]: `Are you sure you want to delete ${name.value}?`, } as Record const successLabelMap = { [StudioItemActionId.RevertItem]: 'Revert changes', + [StudioItemActionId.DeleteItem]: 'Delete', } as Record const successMessageMap = { - [StudioItemActionId.RevertItem]: 'Changes reverted successfully!', + [StudioItemActionId.RevertItem]: 'Revert successful!', + [StudioItemActionId.DeleteItem]: 'Deletion successful!', } as Record const errorMessageMap = { [StudioItemActionId.RevertItem]: 'Something went wrong while reverting your file.', + [StudioItemActionId.DeleteItem]: 'Something went wrong while deleting your file.', } as Record const handleConfirm = async () => { diff --git a/src/app/src/composables/useContext.ts b/src/app/src/composables/useContext.ts index 47ae1c11..8ecdb21a 100644 --- a/src/app/src/composables/useContext.ts +++ b/src/app/src/composables/useContext.ts @@ -7,6 +7,7 @@ import type { useDraftDocuments } from './useDraftDocuments' import { useModal } from './useModal' import type { useTree } from './useTree' import type { useDraftMedias } from './useDraftMedias' +import { findDescendantsFileItemsFromId } from '../utils/tree' export const useContext = createSharedComposable(( host: StudioHost, @@ -58,7 +59,7 @@ export const useContext = createSharedComposable(( [StudioItemActionId.CreateDocument]: async ({ fsPath, routePath, content }: CreateFileParams) => { const document = await host.document.create(fsPath, routePath, content) const draftItem = await draft.value.create(document) - tree.selectItemById(draftItem.id) + await tree.selectItemById(draftItem.id) }, [StudioItemActionId.UploadMedia]: async ({ directory, files }: UploadMediaParams) => { for (const file of files) { @@ -66,7 +67,6 @@ export const useContext = createSharedComposable(( } }, [StudioItemActionId.RevertItem]: async (id: string) => { - console.log('revert item', id) modal.openConfirmActionModal(id, StudioItemActionId.RevertItem, async () => { await draft.value.revert(id) }) @@ -75,7 +75,11 @@ export const useContext = createSharedComposable(( alert(`rename file ${path} ${file.name}`) }, [StudioItemActionId.DeleteItem]: async (id: string) => { - alert(`delete file ${id}`) + modal.openConfirmActionModal(id, StudioItemActionId.DeleteItem, async () => { + const ids: string[] = findDescendantsFileItemsFromId(tree.root.value, id).map(item => item.id) + await draft.value.remove(ids) + await tree.selectParentById(id) + }) }, [StudioItemActionId.DuplicateItem]: async (id: string) => { alert(`duplicate file ${id}`) diff --git a/src/app/src/composables/useDraftDocuments.ts b/src/app/src/composables/useDraftDocuments.ts index a525b61a..658bdd60 100644 --- a/src/app/src/composables/useDraftDocuments.ts +++ b/src/app/src/composables/useDraftDocuments.ts @@ -88,56 +88,65 @@ export const useDraftDocuments = createSharedComposable((host: StudioHost, git: return existingItem } - async function remove(id: string) { - const item = await storage.getItem(id) as DraftItem - const fsPath = host.document.getFileSystemPath(id) - - if (item) { - if (item.status === DraftStatus.Deleted) return + async function remove(ids: string[]) { + for (const id of ids) { + const existingItem = list.value.find(item => item.id === id) + const fsPath = host.document.getFileSystemPath(id) await storage.removeItem(id) await host.document.delete(id) - if (item.original) { - const deleteDraft: DraftItem = { + let deleteDraftItem: DraftItem | null = null + if (existingItem) { + if (existingItem.status === DraftStatus.Deleted) return + + if (existingItem.status === DraftStatus.Created) { + list.value = list.value.filter(item => item.id !== id) + } + else { + deleteDraftItem = { + id, + fsPath: existingItem.fsPath, + status: DraftStatus.Deleted, + original: existingItem.original, + githubFile: existingItem.githubFile, + } + + list.value = list.value.map(item => item.id === id ? deleteDraftItem! : item) + } + } + else { + // TODO: check if gh file has been updated + const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile + const original = await host.document.get(id) + + deleteDraftItem = { id, - fsPath: item.fsPath, + fsPath, status: DraftStatus.Deleted, - original: item.original, - githubFile: item.githubFile, + original, + githubFile, } - await storage.setItem(id, deleteDraft) - await host.document.upsert(id, item.original!) + list.value.push(deleteDraftItem) } - } - else { - // Fetch github file before creating draft to detect non deployed changes - const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile - const original = await host.document.get(id) - - const deleteItem: DraftItem = { - id, - fsPath, - status: DraftStatus.Deleted, - original, - githubFile, + + console.log('List value', list.value) + + if (deleteDraftItem) { + console.log('Set delete draft item in storage', deleteDraftItem) + await storage.setItem(id, deleteDraftItem) } - await storage.setItem(id, deleteItem) + host.app.requestRerender() - await host.document.delete(id) + await hooks.callHook('studio:draft:document:updated') } - - list.value = list.value.filter(item => item.id !== id) - host.app.requestRerender() } async function revert(id: string) { const draftItems = findDescendantsFromId(list.value, id) - console.log('draftItems', draftItems) - for (const draftItem of draftItems) { const existingItem = list.value.find(item => item.id === draftItem.id) if (!existingItem) { @@ -206,6 +215,11 @@ export const useDraftDocuments = createSharedComposable((host: StudioHost, git: } function select(draftItem: DraftItem | null) { + // TODO: Handle editor with deleted file + if (draftItem?.status === DraftStatus.Deleted) { + return + } + current.value = draftItem } diff --git a/src/app/src/composables/useDraftMedias.ts b/src/app/src/composables/useDraftMedias.ts index 411123be..da1b8090 100644 --- a/src/app/src/composables/useDraftMedias.ts +++ b/src/app/src/composables/useDraftMedias.ts @@ -82,36 +82,38 @@ export const useDraftMedias = createSharedComposable((host: StudioHost, git: Ret return existingItem } - async function remove(id: string) { - const item = await storage.getItem(id) as DraftItem - const fsPath = host.media.getFileSystemPath(id) + async function remove(ids: string[]) { + for (const id of ids) { + const item = await storage.getItem(id) as DraftItem + const fsPath = host.media.getFileSystemPath(id) - if (item) { - if (item.status === DraftStatus.Deleted) return + if (item) { + if (item.status === DraftStatus.Deleted) return - await storage.removeItem(id) - await host.media.delete(id) - } - else { + await storage.removeItem(id) + await host.media.delete(id) + } + else { // Fetch github file before creating draft to detect non deployed changes - const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile - const original = await host.media.get(id) + const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile + const original = await host.media.get(id) + + const deleteItem: DraftItem = { + id, + fsPath, + status: DraftStatus.Deleted, + original, + githubFile, + } - const deleteItem: DraftItem = { - id, - fsPath, - status: DraftStatus.Deleted, - original, - githubFile, - } + await storage.setItem(id, deleteItem) - await storage.setItem(id, deleteItem) + await host.media.delete(id) + } - await host.media.delete(id) + list.value = list.value.filter(item => item.id !== id) + host.app.requestRerender() } - - list.value = list.value.filter(item => item.id !== id) - host.app.requestRerender() } async function revert(id: string) { diff --git a/src/app/src/composables/useStudio.ts b/src/app/src/composables/useStudio.ts index 163d90f1..02c363b4 100644 --- a/src/app/src/composables/useStudio.ts +++ b/src/app/src/composables/useStudio.ts @@ -37,9 +37,9 @@ export const useStudio = createSharedComposable(() => { host.app.requestRerender() isReady.value = true - host.on.routeChange((to: RouteLocationNormalized, _from: RouteLocationNormalized) => { + host.on.routeChange(async (to: RouteLocationNormalized, _from: RouteLocationNormalized) => { if (ui.isPanelOpen.value && ui.config.value.syncEditorAndRoute) { - documentTree.selectByRoute(to) + await documentTree.selectByRoute(to) } // setTimeout(() => { // host.document.detectActives() diff --git a/src/app/src/composables/useTree.ts b/src/app/src/composables/useTree.ts index d0cfbfd7..4fa366ed 100644 --- a/src/app/src/composables/useTree.ts +++ b/src/app/src/composables/useTree.ts @@ -2,7 +2,7 @@ import { StudioFeature, type StudioHost, type TreeItem } from '../types' import { ref, computed } from 'vue' import type { useDraftDocuments } from './useDraftDocuments' import type { useDraftMedias } from './useDraftMedias' -import { buildTree, findItemFromId, findItemFromRoute, ROOT_ITEM } from '../utils/tree' +import { buildTree, findItemFromId, findItemFromRoute, ROOT_ITEM, findParentFromId } from '../utils/tree' import type { RouteLocationNormalized } from 'vue-router' import { useHooks } from './useHooks' @@ -56,7 +56,7 @@ export const useTree = (type: StudioFeature, host: StudioHost, draft: ReturnType if (!item || item.id === currentItem.value.id) return - select(item) + await select(item) } async function selectItemById(id: string) { @@ -64,7 +64,14 @@ export const useTree = (type: StudioFeature, host: StudioHost, draft: ReturnType if (!treeItem || treeItem.id === currentItem.value.id) return - select(treeItem) + await select(treeItem) + } + + async function selectParentById(id: string) { + const parent = findParentFromId(tree.value, id) + if (parent) { + await select(parent) + } } async function handleDraftUpdate() { @@ -102,5 +109,6 @@ export const useTree = (type: StudioFeature, host: StudioHost, draft: ReturnType select, selectByRoute, selectItemById, + selectParentById, } } diff --git a/src/app/src/utils/context.ts b/src/app/src/utils/context.ts index 2b0c0b3b..87faafda 100644 --- a/src/app/src/utils/context.ts +++ b/src/app/src/utils/context.ts @@ -93,6 +93,7 @@ export function computeActionItems(itemActions: StudioAction[], item?: TreeItem export function computeActionParams(action: StudioItemActionId, { item }: { item: TreeItem }): ActionHandlerParams[typeof action] { switch (action) { case StudioItemActionId.RevertItem: + case StudioItemActionId.DeleteItem: return item.id default: return {} diff --git a/src/app/src/utils/draft.ts b/src/app/src/utils/draft.ts index 9ba66610..cd88e5a3 100644 --- a/src/app/src/utils/draft.ts +++ b/src/app/src/utils/draft.ts @@ -13,7 +13,7 @@ export const COLOR_STATUS_MAP: { [key in DraftStatus]?: string } = { export const COLOR_UI_STATUS_MAP: { [key in DraftStatus]?: string } = { [DraftStatus.Created]: 'success', [DraftStatus.Updated]: 'warning', - [DraftStatus.Deleted]: 'danger', + [DraftStatus.Deleted]: 'error', [DraftStatus.Renamed]: 'info', [DraftStatus.Opened]: 'neutral', } as const diff --git a/src/app/src/utils/tree.ts b/src/app/src/utils/tree.ts index 8a9b3a40..0eb730b0 100644 --- a/src/app/src/utils/tree.ts +++ b/src/app/src/utils/tree.ts @@ -21,6 +21,9 @@ TreeItem[] { const tree: TreeItem[] = [] const directoryMap = new Map() + const deletedDraftItems = draftList?.filter(draft => draft.status === DraftStatus.Deleted) || [] + const updatedDraftItems = draftList?.filter(draft => draft.status !== DraftStatus.Deleted) || [] + for (const dbItem of dbItems) { const itemHasPathField = 'path' in dbItem && dbItem.path const fsPathSegments = dbItem.fsPath.split('/') @@ -54,7 +57,7 @@ TreeItem[] { fileItem.routePath = dbItem.path as string } - const draftFileItem = draftList?.find(draft => draft.id === dbItem.id) + const draftFileItem = updatedDraftItems?.find(draft => draft.id === dbItem.id) if (draftFileItem) { fileItem.status = draftFileItem.status } @@ -121,7 +124,7 @@ TreeItem[] { type: 'file', } - const draftFileItem = draftList?.find(draft => draft.id === dbItem.id) + const draftFileItem = updatedDraftItems?.find(draft => draft.id === dbItem.id) if (draftFileItem) { fileItem.status = draftFileItem.status } @@ -133,6 +136,8 @@ TreeItem[] { directoryChildren.push(fileItem) } + addDeletedDraftItems(tree, deletedDraftItems) + calculateDirectoryStatuses(tree) return tree @@ -192,14 +197,43 @@ export function findItemFromRoute(tree: TreeItem[], route: RouteLocationNormaliz return null } -export function findDescendantsFromId(tree: TreeItem[], id: string): TreeItem[] { +export function findDescendantsFileItemsFromId(tree: TreeItem[], id: string): TreeItem[] { const descendants: TreeItem[] = [] - for (const item of tree) { - if (item.id === id) { - descendants.push(item) + + function traverse(items: TreeItem[]) { + for (const item of items) { + // Check if this item matches the id or is a descendant of it + if (item.id === id || item.id.startsWith(id + '/')) { + if (item.type === 'file') { + descendants.push(item) + } + + // If this item has children, add all of them as descendants + if (item.children) { + getAllDescendants(item.children, descendants) + } + } + else if (item.children) { + // Continue searching in children + traverse(item.children) + } + } + } + + function getAllDescendants(items: TreeItem[], result: TreeItem[]) { + for (const item of items) { + if (item.type === 'file') { + result.push(item) + } + + if (item.children) { + getAllDescendants(item.children, result) + } } } + traverse(tree) + return descendants } @@ -217,3 +251,50 @@ function calculateDirectoryStatuses(items: TreeItem[]) { } } } + +function addDeletedDraftItems(tree: TreeItem[], deletedItems: DraftItem[]) { + for (const deletedItem of deletedItems) { + const idSegments = deletedItem.id.split('/') + const fileName = idSegments[idSegments.length - 1] + const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '') + + const parentId = idSegments.slice(0, -1).join('/') + const parentDir = findItemFromId(tree, parentId) + + if (parentDir) { + const deletedTreeItem: TreeItem = { + id: deletedItem.id, + name: stripNumericPrefix(fileNameWithoutExtension), + fsPath: deletedItem.fsPath, + type: 'file', + status: DraftStatus.Deleted, + } + + if (parentDir.routePath) { + deletedTreeItem.routePath = `${parentDir.routePath}/${stripNumericPrefix(fileNameWithoutExtension)}` + } + + // Add to parent's children + parentDir.children!.push(deletedTreeItem) + } + else { + const parentFsPath = deletedItem.fsPath.split('/').slice(0, -1).join('/') + + const newDir: TreeItem = { + id: parentId, + name: stripNumericPrefix(parentFsPath.split('/').pop()!), + fsPath: parentFsPath, + type: 'directory', + children: [{ + id: deletedItem.id, + name: stripNumericPrefix(fileNameWithoutExtension), + fsPath: deletedItem.fsPath, + type: 'file', + status: DraftStatus.Deleted, + }], + } + + tree.push(newDir) + } + } +} diff --git a/src/app/test/mocks/tree.ts b/src/app/test/mocks/tree.ts index 8d17f2a6..89a0833c 100644 --- a/src/app/test/mocks/tree.ts +++ b/src/app/test/mocks/tree.ts @@ -4,40 +4,40 @@ export const tree: TreeItem[] = [ { id: 'landing/index.md', name: 'home', - fsPath: '/index.md', + fsPath: 'index.md', type: 'file', routePath: '/', }, { id: 'docs/1.getting-started', name: 'getting-started', - fsPath: '/getting-started', + fsPath: 'getting-started', type: 'directory', children: [ { id: 'docs/1.getting-started/2.introduction.md', name: 'introduction', - fsPath: '/1.getting-started/2.introduction.md', + fsPath: '1.getting-started/2.introduction.md', type: 'file', routePath: '/getting-started/introduction', }, { id: 'docs/1.getting-started/3.installation.md', name: 'installation', - fsPath: '/1.getting-started/3.installation.md', + fsPath: '1.getting-started/3.installation.md', type: 'file', routePath: '/getting-started/installation', }, { id: 'docs/1.getting-started/1.advanced', name: 'advanced', - fsPath: '/1.getting-started/1.advanced', + fsPath: '1.getting-started/1.advanced', type: 'directory', children: [ { id: 'docs/1.getting-started/1.advanced/1.studio.md', name: 'studio', - fsPath: '/1.getting-started/1.advanced/1.studio.md', + fsPath: '1.getting-started/1.advanced/1.studio.md', type: 'file', routePath: '/getting-started/installation/advanced/studio', }, diff --git a/src/app/test/utils/tree.test.ts b/src/app/test/utils/tree.test.ts index d88ba642..0f0f4061 100644 --- a/src/app/test/utils/tree.test.ts +++ b/src/app/test/utils/tree.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildTree, findParentFromId, findItemFromRoute, findItemFromId } from '../../src/utils/tree' +import { buildTree, findParentFromId, findItemFromRoute, findItemFromId, findDescendantsFileItemsFromId } from '../../src/utils/tree' import { tree } from '../mocks/tree' import type { TreeItem } from '../../src/types/tree' import { dbItemsList } from '../mocks/database' @@ -42,12 +42,12 @@ describe('buildTree', () => { }, ] - it('should build a tree from a list of database items with empty draft', () => { + it('Db items list without draft', () => { const tree = buildTree(dbItemsList, null) expect(tree).toStrictEqual(result) }) - it('should build a tree from a list of database items and set file status for root file based on draft', () => { + it('Db items list with draft', () => { const draftList: DraftItem[] = [{ id: dbItemsList[0].id, fsPath: 'index.md', @@ -63,7 +63,100 @@ describe('buildTree', () => { ...result.slice(1)]) }) - it('should build a tree from a list of database items and set status for nested file and parent directory based on draft', () => { + it('Db items list with DELETED file in exsiting directory in draft (directory status is set)', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.getting-started/2.deleted.md', + fsPath: '1.getting-started/2.deleted.md', + status: DraftStatus.Deleted, + }] + + const tree = buildTree(dbItemsList, draftList) + + expect(tree).toStrictEqual([ + { ...result[0] }, + { + ...result[1], + status: DraftStatus.Updated, + children: [ + ...result[1].children!, + { + id: 'docs/1.getting-started/2.deleted.md', + name: 'deleted', + fsPath: '1.getting-started/2.deleted.md', + type: 'file', + routePath: '/getting-started/deleted', + status: DraftStatus.Deleted, + }, + ], + }, + ]) + }) + + it('Db items list with DELETED file in non existing directory in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.deleted-directory/2.deleted-file.md', + fsPath: '1.deleted-directory/2.deleted-file.md', + status: DraftStatus.Deleted, + }] + + const tree = buildTree(dbItemsList, draftList) + + expect(tree).toStrictEqual([ + ...result, + { + id: 'docs/1.deleted-directory', + name: 'deleted-directory', + fsPath: '1.deleted-directory', + type: 'directory', + status: DraftStatus.Updated, + children: [ + { + id: 'docs/1.deleted-directory/2.deleted-file.md', + name: 'deleted-file', + fsPath: '1.deleted-directory/2.deleted-file.md', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }, + ]) + }) + + it('Db items list with all DELETED files in existing directory in draft (directory status is set to DELETED)', () => { + const draftList: DraftItem[] = [{ + id: dbItemsList[1].id, + fsPath: '1.getting-started/2.introduction.md', + status: DraftStatus.Deleted, + }, { + id: dbItemsList[2].id, + fsPath: '1.getting-started/3.installation.md', + status: DraftStatus.Deleted, + }] + + const tree = buildTree(dbItemsList, draftList) + + console.log('Tree', tree) + + expect(tree).toStrictEqual([ + result[0], + { + ...result[1], + status: DraftStatus.Deleted, + children: [ + { + ...result[1].children![0], + status: DraftStatus.Deleted, + }, + { + ...result[1].children![1], + status: DraftStatus.Deleted, + }, + ], + }, + ]) + }) + + it('Db items list with UPDATED file in exsiting directory in draft (directory status is set)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', @@ -89,7 +182,7 @@ describe('buildTree', () => { expect(tree).toStrictEqual(expectedTree) }) - it('should build a tree from a list of database items and set status for nested files. Directory status is always UPDATED if at least one child is other then OPENED', () => { + it('Db items list with UPDATED and OPENED files in exsiting directory in draft (directory status is set)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', @@ -118,7 +211,7 @@ describe('buildTree', () => { expect(tree).toStrictEqual(expectedTree) }) - it('should build a tree from a list of database items and set OPENED status for nestedfile. Parent directory status is not set if all children are OPENED', () => { + it('Db items list with OPENED files in exsiting directory in draft (directory status is not set)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', @@ -279,3 +372,48 @@ describe('findItemFromId', () => { expect(item).toBeNull() }) }) + +describe('findDescendantsFileItemsFromId', () => { + it('returns exact match for a root level file', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'landing/index.md') + expect(descendants).toHaveLength(1) + expect(descendants[0].id).toBe('landing/index.md') + }) + + it('returns empty array for non-existent id', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'non-existent/file.md') + expect(descendants).toHaveLength(0) + }) + + it('returns all descendants files for directory id', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'docs/1.getting-started') + + expect(descendants).toHaveLength(3) + + expect(descendants.some(item => item.id === 'docs/1.getting-started/2.introduction.md')).toBe(true) + expect(descendants.some(item => item.id === 'docs/1.getting-started/3.installation.md')).toBe(true) + expect(descendants.some(item => item.id === 'docs/1.getting-started/1.advanced/1.studio.md')).toBe(true) + }) + + it('returns all descendants files for nested directory id', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'docs/1.getting-started/1.advanced') + + expect(descendants).toHaveLength(1) + + expect(descendants.some(item => item.id === 'docs/1.getting-started/1.advanced/1.studio.md')).toBe(true) + }) + + it('returns only the file itself when searching for a specific file', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'docs/1.getting-started/2.introduction.md') + + expect(descendants).toHaveLength(1) + expect(descendants[0].id).toBe('docs/1.getting-started/2.introduction.md') + }) + + it('returns deeply nested file when searching by specific file id', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'docs/1.getting-started/1.advanced/1.studio.md') + + expect(descendants).toHaveLength(1) + expect(descendants[0].id).toBe('docs/1.getting-started/1.advanced/1.studio.md') + }) +}) From a44ed6a836284b293fdce1c6ee46057775955fb1 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Mon, 29 Sep 2025 10:28:41 +0200 Subject: [PATCH 2/4] up --- src/app/src/composables/useDraftDocuments.ts | 3 - src/app/src/utils/tree.ts | 158 ++++++++-- src/app/test/mocks/database.ts | 41 +++ src/app/test/utils/tree.test.ts | 314 ++++++++++++++++++- 4 files changed, 469 insertions(+), 47 deletions(-) diff --git a/src/app/src/composables/useDraftDocuments.ts b/src/app/src/composables/useDraftDocuments.ts index 658bdd60..c1223891 100644 --- a/src/app/src/composables/useDraftDocuments.ts +++ b/src/app/src/composables/useDraftDocuments.ts @@ -131,10 +131,7 @@ export const useDraftDocuments = createSharedComposable((host: StudioHost, git: list.value.push(deleteDraftItem) } - console.log('List value', list.value) - if (deleteDraftItem) { - console.log('Set delete draft item in storage', deleteDraftItem) await storage.setItem(id, deleteDraftItem) } diff --git a/src/app/src/utils/tree.ts b/src/app/src/utils/tree.ts index 0eb730b0..779866ae 100644 --- a/src/app/src/utils/tree.ts +++ b/src/app/src/utils/tree.ts @@ -242,10 +242,19 @@ function calculateDirectoryStatuses(items: TreeItem[]) { if (item.type === 'directory' && item.children) { calculateDirectoryStatuses(item.children) - for (const child of item.children) { - if (child.status && child.status !== DraftStatus.Opened) { + const childrenWithStatus = item.children.filter(child => child.status && child.status !== DraftStatus.Opened) + + if (childrenWithStatus.length > 0) { + // Check if ALL children with status are deleted + const allDeleted = childrenWithStatus.every(child => child.status === DraftStatus.Deleted) + + if (allDeleted && childrenWithStatus.length === item.children.length) { + // If all children are deleted, mark directory as deleted + item.status = DraftStatus.Deleted + } + else { + // Otherwise, mark as updated item.status = DraftStatus.Updated - break } } } @@ -254,47 +263,128 @@ function calculateDirectoryStatuses(items: TreeItem[]) { function addDeletedDraftItems(tree: TreeItem[], deletedItems: DraftItem[]) { for (const deletedItem of deletedItems) { - const idSegments = deletedItem.id.split('/') - const fileName = idSegments[idSegments.length - 1] - const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '') - - const parentId = idSegments.slice(0, -1).join('/') - const parentDir = findItemFromId(tree, parentId) - - if (parentDir) { - const deletedTreeItem: TreeItem = { - id: deletedItem.id, - name: stripNumericPrefix(fileNameWithoutExtension), - fsPath: deletedItem.fsPath, - type: 'file', - status: DraftStatus.Deleted, - } - - if (parentDir.routePath) { - deletedTreeItem.routePath = `${parentDir.routePath}/${stripNumericPrefix(fileNameWithoutExtension)}` - } + const existingItem = findItemFromId(tree, deletedItem.id) - // Add to parent's children - parentDir.children!.push(deletedTreeItem) + // Update existing item status to Deleted + if (existingItem) { + existingItem.status = DraftStatus.Deleted } + // Create new deleted item else { - const parentFsPath = deletedItem.fsPath.split('/').slice(0, -1).join('/') - - const newDir: TreeItem = { - id: parentId, - name: stripNumericPrefix(parentFsPath.split('/').pop()!), - fsPath: parentFsPath, - type: 'directory', - children: [{ + const idSegments = deletedItem.id.split('/') + const fsPathSegments = deletedItem.fsPath.split('/') + const fileName = idSegments[idSegments.length - 1] + const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '') + + const parentId = idSegments.slice(0, -1).join('/') + const parentDir = findItemFromId(tree, parentId) + + // Add to existing directory + if (parentDir) { + const deletedTreeItem: TreeItem = { id: deletedItem.id, name: stripNumericPrefix(fileNameWithoutExtension), fsPath: deletedItem.fsPath, type: 'file', status: DraftStatus.Deleted, - }], + } + + if (parentDir.routePath) { + deletedTreeItem.routePath = `${parentDir.routePath}/${stripNumericPrefix(fileNameWithoutExtension)}` + } + + parentDir.children!.push(deletedTreeItem) } + // Create parent directories chain + else { + let existingParent: TreeItem | null = null + let existingParentLevel = -1 + + // Find the deepest existing parent directory + for (let i = idSegments.length - 2; i >= 0; i--) { + const potentialParentId = idSegments.slice(0, i + 1).join('/') + const potentialParent = findItemFromId(tree, potentialParentId) + if (potentialParent && potentialParent.type === 'directory') { + existingParent = potentialParent + existingParentLevel = i + break + } + } - tree.push(newDir) + // If we found an existing ancestor, create parent directories chain + if (existingParent) { + let currentParent = existingParent + let currentChildren = existingParent.children! + + const idFsPathOffset = idSegments.length - fsPathSegments.length + + // Create missing intermediate directories + for (let i = existingParentLevel + 1; i < idSegments.length - 1; i++) { + const dirId = idSegments.slice(0, i + 1).join('/') + const fsPathIndex = i - idFsPathOffset + + const dirFsPath = fsPathIndex >= 0 + ? fsPathSegments.slice(0, fsPathIndex + 1).join('/') + : '' + const dirName = fsPathIndex >= 0 + ? stripNumericPrefix(fsPathSegments[fsPathIndex]) + : stripNumericPrefix(idSegments[i]) + + const newDir: TreeItem = { + id: dirId, + name: dirName, + fsPath: dirFsPath, + type: 'directory', + status: DraftStatus.Deleted, + children: [], + } + + if (currentParent.routePath) { + newDir.routePath = `${currentParent.routePath}/${dirName}` + } + + currentChildren.push(newDir) + currentParent = newDir + currentChildren = newDir.children! + } + + // Create the deleted file in the last directory + const deletedTreeItem: TreeItem = { + id: deletedItem.id, + name: stripNumericPrefix(fileNameWithoutExtension), + fsPath: deletedItem.fsPath, + type: 'file', + status: DraftStatus.Deleted, + } + + if (currentParent.routePath) { + deletedTreeItem.routePath = `${currentParent.routePath}/${stripNumericPrefix(fileNameWithoutExtension)}` + } + + currentChildren.push(deletedTreeItem) + } + // No existing parent found - create at root level + else { + const parentFsPath = deletedItem.fsPath.split('/').slice(0, -1).join('/') + + const newDir: TreeItem = { + id: parentId, + name: stripNumericPrefix(parentFsPath.split('/').pop()!), + fsPath: parentFsPath, + type: 'directory', + status: DraftStatus.Deleted, + children: [{ + id: deletedItem.id, + name: stripNumericPrefix(fileNameWithoutExtension), + fsPath: deletedItem.fsPath, + type: 'file', + status: DraftStatus.Deleted, + }], + } + + tree.push(newDir) + } + } } } } diff --git a/src/app/test/mocks/database.ts b/src/app/test/mocks/database.ts index dc9e9184..2f36fcb7 100644 --- a/src/app/test/mocks/database.ts +++ b/src/app/test/mocks/database.ts @@ -59,3 +59,44 @@ export const dbItemsList: (DatabaseItem & { fsPath: string })[] = [ __hash__: 'EXAMPLE_HASH_FOR_INSTALLATION', }, ] + +export const nestedDbItemsList: (DatabaseItem & { fsPath: string })[] = [ + { + id: 'docs/1.essentials/2.configuration.md', + title: 'Configuration', + body: { type: 'minimark', value: [] }, + description: 'Learn how to configure Docus.', + extension: 'md', + layout: null, + links: null, + meta: {}, + navigation: {}, + path: '/essentials/configuration', + fsPath: '1.essentials/2.configuration.md', + seo: { + title: 'Configuration', + description: 'Learn how to configure Docus.', + }, + stem: '1.essentials/2.configuration', + __hash__: 'EXAMPLE_HASH_FOR_CONFIGURATION', + }, + { + id: 'docs/1.essentials/1.nested/2.advanced.md', + title: 'Advanced', + body: { type: 'minimark', value: [] }, + description: 'Learn how to configure Docus.', + extension: 'md', + layout: null, + links: null, + meta: {}, + navigation: {}, + path: '/essentials/nested/advanced', + fsPath: '1.essentials/1.nested/2.advanced.md', + seo: { + title: 'Advanced', + description: 'Learn how to use advanced features in Docus.', + }, + stem: '1.essentials/1.nested/2.advanced', + __hash__: 'EXAMPLE_HASH_FOR_ADVANCED', + }, +] diff --git a/src/app/test/utils/tree.test.ts b/src/app/test/utils/tree.test.ts index 0f0f4061..05ac0802 100644 --- a/src/app/test/utils/tree.test.ts +++ b/src/app/test/utils/tree.test.ts @@ -2,12 +2,12 @@ import { describe, it, expect } from 'vitest' import { buildTree, findParentFromId, findItemFromRoute, findItemFromId, findDescendantsFileItemsFromId } from '../../src/utils/tree' import { tree } from '../mocks/tree' import type { TreeItem } from '../../src/types/tree' -import { dbItemsList } from '../mocks/database' +import { dbItemsList, nestedDbItemsList } from '../mocks/database' import type { DraftItem } from '../../src/types/draft' import { DraftStatus } from '../../src/types/draft' import type { RouteLocationNormalized } from 'vue-router' -describe('buildTree', () => { +describe('buildTree with one level of depth', () => { // Result based on dbItemsList mock const result: TreeItem[] = [ { @@ -63,7 +63,7 @@ describe('buildTree', () => { ...result.slice(1)]) }) - it('Db items list with DELETED file in exsiting directory in draft (directory status is set)', () => { + it('Db items list with DELETED non existing file in existing directory in draft (directory status is set)', () => { const draftList: DraftItem[] = [{ id: 'docs/1.getting-started/2.deleted.md', fsPath: '1.getting-started/2.deleted.md', @@ -92,7 +92,7 @@ describe('buildTree', () => { ]) }) - it('Db items list with DELETED file in non existing directory in draft', () => { + it('Db items list with DELETED non existing file in non existing directory in draft', () => { const draftList: DraftItem[] = [{ id: 'docs/1.deleted-directory/2.deleted-file.md', fsPath: '1.deleted-directory/2.deleted-file.md', @@ -108,7 +108,7 @@ describe('buildTree', () => { name: 'deleted-directory', fsPath: '1.deleted-directory', type: 'directory', - status: DraftStatus.Updated, + status: DraftStatus.Deleted, children: [ { id: 'docs/1.deleted-directory/2.deleted-file.md', @@ -122,7 +122,7 @@ describe('buildTree', () => { ]) }) - it('Db items list with all DELETED files in existing directory in draft (directory status is set to DELETED)', () => { + it('Db items list with all DELETED existing files in existing directory in draft (directory status is set to DELETED)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', @@ -135,8 +135,6 @@ describe('buildTree', () => { const tree = buildTree(dbItemsList, draftList) - console.log('Tree', tree) - expect(tree).toStrictEqual([ result[0], { @@ -156,7 +154,10 @@ describe('buildTree', () => { ]) }) - it('Db items list with UPDATED file in exsiting directory in draft (directory status is set)', () => { + it('Db items list with all DELETED existing files non in existing directory in draft (directory status is set to DELETED)', () => { + }) + + it('Db items list with UPDATED file in existing directory in draft (directory status is set)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', @@ -211,7 +212,7 @@ describe('buildTree', () => { expect(tree).toStrictEqual(expectedTree) }) - it('Db items list with OPENED files in exsiting directory in draft (directory status is not set)', () => { + it('Db items list with OPENED files in existing directory in draft (directory status is not set)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', @@ -241,6 +242,299 @@ describe('buildTree', () => { }) }) +describe('buildTree with two levels of depth', () => { + const result: TreeItem[] = [ + { + id: 'docs/1.essentials', + name: 'essentials', + fsPath: '1.essentials', + routePath: '/essentials', + type: 'directory', + children: [ + { + id: 'docs/1.essentials/2.configuration.md', + name: 'configuration', + fsPath: '1.essentials/2.configuration.md', + type: 'file', + routePath: '/essentials/configuration', + }, + { + id: 'docs/1.essentials/1.nested', + name: 'nested', + fsPath: '1.essentials/1.nested', + routePath: '/essentials/nested', + type: 'directory', + children: [ + { + id: 'docs/1.essentials/1.nested/2.advanced.md', + name: 'advanced', + fsPath: '1.essentials/1.nested/2.advanced.md', + type: 'file', + routePath: '/essentials/nested/advanced', + }, + ], + }, + ], + }, + ] + + it('Db items list without draft', () => { + const tree = buildTree(nestedDbItemsList, null) + expect(tree).toStrictEqual(result) + }) + + it('Db items list with one level of depth draft', () => { + const draftList: DraftItem[] = [{ + id: nestedDbItemsList[0].id, + fsPath: '1.essentials/2.configuration.md', + status: DraftStatus.Updated, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + { ...result[0].children![0], status: DraftStatus.Updated }, + result[0].children![1], + ], + }]) + }) + + it('Db items list with nested levels of depth draft', () => { + const draftList: DraftItem[] = [{ + id: nestedDbItemsList[1].id, + fsPath: '1.essentials/1.nested/2.advanced.md', + status: DraftStatus.Updated, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + { + ...result[0].children![1], + status: DraftStatus.Updated, + children: [ + { + ...result[0].children![1].children![0], + status: DraftStatus.Updated, + }, + ], + }, + ], + }]) + }) + + it('Db items list with DELETED existing file in first level directory in draft', () => { + const draftList: DraftItem[] = [{ + id: nestedDbItemsList[0].id, + fsPath: '1.essentials/2.configuration.md', + status: DraftStatus.Deleted, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + { + ...result[0].children![0], + status: DraftStatus.Deleted, + }, + result[0].children![1], + ], + }]) + }) + + it ('Db items list with DELETED existing file in nested existing directory in draft (directory status is set to DELETED)', () => { + const draftList: DraftItem[] = [{ + id: nestedDbItemsList[1].id, + fsPath: '1.essentials/1.nested/2.advanced.md', + status: DraftStatus.Deleted, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + { + ...result[0].children![1], + status: DraftStatus.Deleted, + children: [ + { + ...result[0].children![1].children![0], + status: DraftStatus.Deleted, + }, + ], + }, + ], + }]) + }) + + it ('Db items list with DELETED non exisitng file in first level existing directory in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.essentials/1.deleted.md', + fsPath: '1.essentials/1.deleted.md', + status: DraftStatus.Deleted, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + result[0].children![1], + { + id: 'docs/1.essentials/1.deleted.md', + name: 'deleted', + fsPath: '1.essentials/1.deleted.md', + routePath: '/essentials/deleted', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }]) + }) + + it ('Db items list with DELETED non existing file in nested existing directory in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.essentials/1.nested/1.deleted.md', + fsPath: '1.essentials/1.nested/1.deleted.md', + status: DraftStatus.Deleted, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + { + ...result[0].children![1], + status: DraftStatus.Updated, + children: [ + result[0].children![1].children![0], + { + id: 'docs/1.essentials/1.nested/1.deleted.md', + name: 'deleted', + fsPath: '1.essentials/1.nested/1.deleted.md', + routePath: '/essentials/nested/deleted', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }, + ], + }]) + }) + + it ('Db items list with DELETED existing file in nested non existing directory in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.essentials/1.deleted/1.deleted.md', + fsPath: '1.essentials/1.deleted/1.deleted.md', + status: DraftStatus.Deleted, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + result[0].children![1], + { + id: 'docs/1.essentials/1.deleted', + name: 'deleted', + fsPath: '1.essentials/1.deleted', + routePath: '/essentials/deleted', + type: 'directory', + status: DraftStatus.Deleted, + children: [ + { + id: 'docs/1.essentials/1.deleted/1.deleted.md', + name: 'deleted', + fsPath: '1.essentials/1.deleted/1.deleted.md', + routePath: '/essentials/deleted/deleted', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }, + ], + }]) + }) + + it ('Db items list with DELETED file in multi-nested non existing directory chain in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.essentials/1.deep/2.deeper/3.deepest/1.file.md', + fsPath: '1.essentials/1.deep/2.deeper/3.deepest/1.file.md', + status: DraftStatus.Deleted, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + result[0].children![1], + { + id: 'docs/1.essentials/1.deep', + name: 'deep', + fsPath: '1.essentials/1.deep', + routePath: '/essentials/deep', + type: 'directory', + status: DraftStatus.Deleted, + children: [ + { + id: 'docs/1.essentials/1.deep/2.deeper', + name: 'deeper', + fsPath: '1.essentials/1.deep/2.deeper', + routePath: '/essentials/deep/deeper', + type: 'directory', + status: DraftStatus.Deleted, + children: [ + { + id: 'docs/1.essentials/1.deep/2.deeper/3.deepest', + name: 'deepest', + fsPath: '1.essentials/1.deep/2.deeper/3.deepest', + routePath: '/essentials/deep/deeper/deepest', + type: 'directory', + status: DraftStatus.Deleted, + children: [ + { + id: 'docs/1.essentials/1.deep/2.deeper/3.deepest/1.file.md', + name: 'file', + fsPath: '1.essentials/1.deep/2.deeper/3.deepest/1.file.md', + routePath: '/essentials/deep/deeper/deepest/file', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }, + ], + }, + ], + }, + ], + }]) + }) +}) + describe('findParentFromId', () => { it('should find direct parent of a child', () => { const parent = findParentFromId(tree, 'docs/1.getting-started/2.introduction.md') From 6efb1c8633025bb714a7400272c658f7c6e2b7f2 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Mon, 29 Sep 2025 11:28:32 +0200 Subject: [PATCH 3/4] up --- src/app/src/utils/context.ts | 12 +-- src/app/src/utils/tree.ts | 163 ++++++-------------------------- src/app/test/utils/tree.test.ts | 38 +++++++- 3 files changed, 69 insertions(+), 144 deletions(-) diff --git a/src/app/src/utils/context.ts b/src/app/src/utils/context.ts index 87faafda..60ea4199 100644 --- a/src/app/src/utils/context.ts +++ b/src/app/src/utils/context.ts @@ -36,18 +36,18 @@ export const STUDIO_ITEM_ACTION_DEFINITIONS: StudioAction[] = [ icon: 'i-lucide-pencil', tooltip: 'Rename file', }, - { - id: StudioItemActionId.DeleteItem, - label: 'Delete', - icon: 'i-lucide-trash', - tooltip: 'Delete file', - }, { id: StudioItemActionId.DuplicateItem, label: 'Duplicate', icon: 'i-lucide-copy', tooltip: 'Duplicate file', }, + { + id: StudioItemActionId.DeleteItem, + label: 'Delete', + icon: 'i-lucide-trash', + tooltip: 'Delete file', + }, ] as const export function computeActionItems(itemActions: StudioAction[], item?: TreeItem | null): StudioAction[] { diff --git a/src/app/src/utils/tree.ts b/src/app/src/utils/tree.ts index 779866ae..25b461d7 100644 --- a/src/app/src/utils/tree.ts +++ b/src/app/src/utils/tree.ts @@ -22,9 +22,34 @@ TreeItem[] { const directoryMap = new Map() const deletedDraftItems = draftList?.filter(draft => draft.status === DraftStatus.Deleted) || [] - const updatedDraftItems = draftList?.filter(draft => draft.status !== DraftStatus.Deleted) || [] - for (const dbItem of dbItems) { + function addDeletedDraftItemsInDbItems(dbItems: ((BaseItem) & { fsPath: string })[], deletedItems: DraftItem[]) { + dbItems = [...dbItems] + for (const deletedItem of deletedItems) { + const existingItem = dbItems.find(dbItem => dbItem.id === deletedItem.id) + if (existingItem) { + console.log('should not happen', deletedItem.id) + continue + } + else { + const virtualDbItems: BaseItem & { fsPath: string } = { + id: deletedItem.id, + extension: deletedItem.id.split('.').pop()!, + stem: '', + fsPath: deletedItem.fsPath, + path: deletedItem.original?.path, + } + + dbItems.push(virtualDbItems) + } + } + + return dbItems + } + + const virtualDbItems = addDeletedDraftItemsInDbItems(dbItems, deletedDraftItems) + + for (const dbItem of virtualDbItems) { const itemHasPathField = 'path' in dbItem && dbItem.path const fsPathSegments = dbItem.fsPath.split('/') const directorySegments = fsPathSegments.slice(0, -1) @@ -57,7 +82,7 @@ TreeItem[] { fileItem.routePath = dbItem.path as string } - const draftFileItem = updatedDraftItems?.find(draft => draft.id === dbItem.id) + const draftFileItem = draftList?.find(draft => draft.id === dbItem.id) if (draftFileItem) { fileItem.status = draftFileItem.status } @@ -124,7 +149,7 @@ TreeItem[] { type: 'file', } - const draftFileItem = updatedDraftItems?.find(draft => draft.id === dbItem.id) + const draftFileItem = draftList?.find(draft => draft.id === dbItem.id) if (draftFileItem) { fileItem.status = draftFileItem.status } @@ -136,8 +161,6 @@ TreeItem[] { directoryChildren.push(fileItem) } - addDeletedDraftItems(tree, deletedDraftItems) - calculateDirectoryStatuses(tree) return tree @@ -260,131 +283,3 @@ function calculateDirectoryStatuses(items: TreeItem[]) { } } } - -function addDeletedDraftItems(tree: TreeItem[], deletedItems: DraftItem[]) { - for (const deletedItem of deletedItems) { - const existingItem = findItemFromId(tree, deletedItem.id) - - // Update existing item status to Deleted - if (existingItem) { - existingItem.status = DraftStatus.Deleted - } - // Create new deleted item - else { - const idSegments = deletedItem.id.split('/') - const fsPathSegments = deletedItem.fsPath.split('/') - const fileName = idSegments[idSegments.length - 1] - const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '') - - const parentId = idSegments.slice(0, -1).join('/') - const parentDir = findItemFromId(tree, parentId) - - // Add to existing directory - if (parentDir) { - const deletedTreeItem: TreeItem = { - id: deletedItem.id, - name: stripNumericPrefix(fileNameWithoutExtension), - fsPath: deletedItem.fsPath, - type: 'file', - status: DraftStatus.Deleted, - } - - if (parentDir.routePath) { - deletedTreeItem.routePath = `${parentDir.routePath}/${stripNumericPrefix(fileNameWithoutExtension)}` - } - - parentDir.children!.push(deletedTreeItem) - } - // Create parent directories chain - else { - let existingParent: TreeItem | null = null - let existingParentLevel = -1 - - // Find the deepest existing parent directory - for (let i = idSegments.length - 2; i >= 0; i--) { - const potentialParentId = idSegments.slice(0, i + 1).join('/') - const potentialParent = findItemFromId(tree, potentialParentId) - if (potentialParent && potentialParent.type === 'directory') { - existingParent = potentialParent - existingParentLevel = i - break - } - } - - // If we found an existing ancestor, create parent directories chain - if (existingParent) { - let currentParent = existingParent - let currentChildren = existingParent.children! - - const idFsPathOffset = idSegments.length - fsPathSegments.length - - // Create missing intermediate directories - for (let i = existingParentLevel + 1; i < idSegments.length - 1; i++) { - const dirId = idSegments.slice(0, i + 1).join('/') - const fsPathIndex = i - idFsPathOffset - - const dirFsPath = fsPathIndex >= 0 - ? fsPathSegments.slice(0, fsPathIndex + 1).join('/') - : '' - const dirName = fsPathIndex >= 0 - ? stripNumericPrefix(fsPathSegments[fsPathIndex]) - : stripNumericPrefix(idSegments[i]) - - const newDir: TreeItem = { - id: dirId, - name: dirName, - fsPath: dirFsPath, - type: 'directory', - status: DraftStatus.Deleted, - children: [], - } - - if (currentParent.routePath) { - newDir.routePath = `${currentParent.routePath}/${dirName}` - } - - currentChildren.push(newDir) - currentParent = newDir - currentChildren = newDir.children! - } - - // Create the deleted file in the last directory - const deletedTreeItem: TreeItem = { - id: deletedItem.id, - name: stripNumericPrefix(fileNameWithoutExtension), - fsPath: deletedItem.fsPath, - type: 'file', - status: DraftStatus.Deleted, - } - - if (currentParent.routePath) { - deletedTreeItem.routePath = `${currentParent.routePath}/${stripNumericPrefix(fileNameWithoutExtension)}` - } - - currentChildren.push(deletedTreeItem) - } - // No existing parent found - create at root level - else { - const parentFsPath = deletedItem.fsPath.split('/').slice(0, -1).join('/') - - const newDir: TreeItem = { - id: parentId, - name: stripNumericPrefix(parentFsPath.split('/').pop()!), - fsPath: parentFsPath, - type: 'directory', - status: DraftStatus.Deleted, - children: [{ - id: deletedItem.id, - name: stripNumericPrefix(fileNameWithoutExtension), - fsPath: deletedItem.fsPath, - type: 'file', - status: DraftStatus.Deleted, - }], - } - - tree.push(newDir) - } - } - } - } -} diff --git a/src/app/test/utils/tree.test.ts b/src/app/test/utils/tree.test.ts index 05ac0802..439c0312 100644 --- a/src/app/test/utils/tree.test.ts +++ b/src/app/test/utils/tree.test.ts @@ -6,6 +6,7 @@ import { dbItemsList, nestedDbItemsList } from '../mocks/database' import type { DraftItem } from '../../src/types/draft' import { DraftStatus } from '../../src/types/draft' import type { RouteLocationNormalized } from 'vue-router' +import type { DatabaseItem } from '../../src/types/database' describe('buildTree with one level of depth', () => { // Result based on dbItemsList mock @@ -63,11 +64,14 @@ describe('buildTree with one level of depth', () => { ...result.slice(1)]) }) - it('Db items list with DELETED non existing file in existing directory in draft (directory status is set)', () => { + it('Db items list with DELETED non existing file in existing directory in draft', () => { const draftList: DraftItem[] = [{ id: 'docs/1.getting-started/2.deleted.md', fsPath: '1.getting-started/2.deleted.md', status: DraftStatus.Deleted, + original: { + path: '/getting-started/deleted', + } as DatabaseItem, }] const tree = buildTree(dbItemsList, draftList) @@ -97,6 +101,9 @@ describe('buildTree with one level of depth', () => { id: 'docs/1.deleted-directory/2.deleted-file.md', fsPath: '1.deleted-directory/2.deleted-file.md', status: DraftStatus.Deleted, + original: { + path: '/deleted-directory/deleted-file', + } as DatabaseItem, }] const tree = buildTree(dbItemsList, draftList) @@ -107,6 +114,7 @@ describe('buildTree with one level of depth', () => { id: 'docs/1.deleted-directory', name: 'deleted-directory', fsPath: '1.deleted-directory', + routePath: '/deleted-directory', type: 'directory', status: DraftStatus.Deleted, children: [ @@ -114,6 +122,7 @@ describe('buildTree with one level of depth', () => { id: 'docs/1.deleted-directory/2.deleted-file.md', name: 'deleted-file', fsPath: '1.deleted-directory/2.deleted-file.md', + routePath: '/deleted-directory/deleted-file', type: 'file', status: DraftStatus.Deleted, }, @@ -127,10 +136,16 @@ describe('buildTree with one level of depth', () => { id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', status: DraftStatus.Deleted, + original: { + path: '/getting-started/introduction', + } as DatabaseItem, }, { id: dbItemsList[2].id, fsPath: '1.getting-started/3.installation.md', status: DraftStatus.Deleted, + original: { + path: '/getting-started/installation', + } as DatabaseItem, }] const tree = buildTree(dbItemsList, draftList) @@ -154,9 +169,6 @@ describe('buildTree with one level of depth', () => { ]) }) - it('Db items list with all DELETED existing files non in existing directory in draft (directory status is set to DELETED)', () => { - }) - it('Db items list with UPDATED file in existing directory in draft (directory status is set)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, @@ -335,6 +347,9 @@ describe('buildTree with two levels of depth', () => { id: nestedDbItemsList[0].id, fsPath: '1.essentials/2.configuration.md', status: DraftStatus.Deleted, + original: { + path: '/essentials/configuration', + } as DatabaseItem, }] const tree = buildTree(nestedDbItemsList, draftList) @@ -357,6 +372,9 @@ describe('buildTree with two levels of depth', () => { id: nestedDbItemsList[1].id, fsPath: '1.essentials/1.nested/2.advanced.md', status: DraftStatus.Deleted, + original: { + path: '/essentials/nested/advanced', + } as DatabaseItem, }] const tree = buildTree(nestedDbItemsList, draftList) @@ -385,6 +403,9 @@ describe('buildTree with two levels of depth', () => { id: 'docs/1.essentials/1.deleted.md', fsPath: '1.essentials/1.deleted.md', status: DraftStatus.Deleted, + original: { + path: '/essentials/deleted', + } as DatabaseItem, }] const tree = buildTree(nestedDbItemsList, draftList) @@ -412,6 +433,9 @@ describe('buildTree with two levels of depth', () => { id: 'docs/1.essentials/1.nested/1.deleted.md', fsPath: '1.essentials/1.nested/1.deleted.md', status: DraftStatus.Deleted, + original: { + path: '/essentials/nested/deleted', + } as DatabaseItem, }] const tree = buildTree(nestedDbItemsList, draftList) @@ -445,6 +469,9 @@ describe('buildTree with two levels of depth', () => { id: 'docs/1.essentials/1.deleted/1.deleted.md', fsPath: '1.essentials/1.deleted/1.deleted.md', status: DraftStatus.Deleted, + original: { + path: '/essentials/deleted/deleted', + } as DatabaseItem, }] const tree = buildTree(nestedDbItemsList, draftList) @@ -482,6 +509,9 @@ describe('buildTree with two levels of depth', () => { id: 'docs/1.essentials/1.deep/2.deeper/3.deepest/1.file.md', fsPath: '1.essentials/1.deep/2.deeper/3.deepest/1.file.md', status: DraftStatus.Deleted, + original: { + path: '/essentials/deep/deeper/deepest/file', + } as DatabaseItem, }] const tree = buildTree(nestedDbItemsList, draftList) From 4b520be1b5b5a3fda4089381ff04f35d98965208 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Mon, 29 Sep 2025 11:36:25 +0200 Subject: [PATCH 4/4] up --- src/app/src/composables/useDraftDocuments.ts | 18 ++++++++--------- src/app/src/utils/tree.ts | 21 +++++++------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/app/src/composables/useDraftDocuments.ts b/src/app/src/composables/useDraftDocuments.ts index c1223891..e768c1a4 100644 --- a/src/app/src/composables/useDraftDocuments.ts +++ b/src/app/src/composables/useDraftDocuments.ts @@ -90,26 +90,27 @@ export const useDraftDocuments = createSharedComposable((host: StudioHost, git: async function remove(ids: string[]) { for (const id of ids) { - const existingItem = list.value.find(item => item.id === id) + const existingDraftItem = list.value.find(item => item.id === id) const fsPath = host.document.getFileSystemPath(id) + const originalDbItem = await host.document.get(id) await storage.removeItem(id) await host.document.delete(id) let deleteDraftItem: DraftItem | null = null - if (existingItem) { - if (existingItem.status === DraftStatus.Deleted) return + if (existingDraftItem) { + if (existingDraftItem.status === DraftStatus.Deleted) return - if (existingItem.status === DraftStatus.Created) { + if (existingDraftItem.status === DraftStatus.Created) { list.value = list.value.filter(item => item.id !== id) } else { deleteDraftItem = { id, - fsPath: existingItem.fsPath, + fsPath: existingDraftItem.fsPath, status: DraftStatus.Deleted, - original: existingItem.original, - githubFile: existingItem.githubFile, + original: existingDraftItem.original, + githubFile: existingDraftItem.githubFile, } list.value = list.value.map(item => item.id === id ? deleteDraftItem! : item) @@ -118,13 +119,12 @@ export const useDraftDocuments = createSharedComposable((host: StudioHost, git: else { // TODO: check if gh file has been updated const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile - const original = await host.document.get(id) deleteDraftItem = { id, fsPath, status: DraftStatus.Deleted, - original, + original: originalDbItem, githubFile, } diff --git a/src/app/src/utils/tree.ts b/src/app/src/utils/tree.ts index 25b461d7..8b706faa 100644 --- a/src/app/src/utils/tree.ts +++ b/src/app/src/utils/tree.ts @@ -26,22 +26,15 @@ TreeItem[] { function addDeletedDraftItemsInDbItems(dbItems: ((BaseItem) & { fsPath: string })[], deletedItems: DraftItem[]) { dbItems = [...dbItems] for (const deletedItem of deletedItems) { - const existingItem = dbItems.find(dbItem => dbItem.id === deletedItem.id) - if (existingItem) { - console.log('should not happen', deletedItem.id) - continue + const virtualDbItems: BaseItem & { fsPath: string } = { + id: deletedItem.id, + extension: deletedItem.id.split('.').pop()!, + stem: '', + fsPath: deletedItem.fsPath, + path: deletedItem.original?.path, } - else { - const virtualDbItems: BaseItem & { fsPath: string } = { - id: deletedItem.id, - extension: deletedItem.id.split('.').pop()!, - stem: '', - fsPath: deletedItem.fsPath, - path: deletedItem.original?.path, - } - dbItems.push(virtualDbItems) - } + dbItems.push(virtualDbItems) } return dbItems