import { ItemView, MarkdownRenderer, Notice, TFile, TFolder, WorkspaceLeaf, ViewStateResult, normalizePath, } from "obsidian"; import { VIEW_TYPE_PROJECT_DETAILS_VIEW, VIEW_TYPE_COLLECTION_VIEW, VIEW_TYPE_PROJECT_VIEW, RIBBON_ICON, CORE_FILE, DESCRIPTION_FILE, } from "../const"; import { projectFolder, subProjectPath, collectionPath, listSubProjects, listCollections, listProjectFeatures, listMarkdownFiles, readFile, rename, deleteRecursive, createCollection, createProject, createProjectFeature, } from "../fs"; import { menu, breadcrumb, emptyState, openMarkdown, BreadcrumbSegment } from "../ui"; import { NameModal } from "../modals/NameModal"; export interface ProjectDetailsState extends Record { projectPath: string; } const NAME_RX = /^[^\\/:*?"<>|]+$/; const PK_DND_MIME = "application/x-pk-item"; interface DndPayload { kind: "feature"; sourcePath: string; name: string; } function validateName(name: string, taken: string[], current?: string): string | null { if (!name) return "Name darf nicht leer sein."; if (!NAME_RX.test(name)) return "Ungültige Zeichen im Namen."; if (taken.includes(name) && name !== current) return "Name existiert bereits."; return null; } export class ProjectDetailsView extends ItemView { projectPath = ""; private renderToken = 0; constructor(leaf: WorkspaceLeaf) { super(leaf); this.navigation = true; } getViewType(): string { return VIEW_TYPE_PROJECT_DETAILS_VIEW; } getDisplayText(): string { if (!this.projectPath) return "Projekt"; const segs = this.projectPath.split("/"); return `Projekt: ${segs[segs.length - 1]}`; } getIcon(): string { return RIBBON_ICON; } async setState(state: ProjectDetailsState, result: ViewStateResult): Promise { this.projectPath = state?.projectPath ?? ""; await super.setState(state, result); await this.render(); } getState(): ProjectDetailsState { return { projectPath: this.projectPath }; } async onOpen(): Promise { await this.render(); this.registerEvent(this.app.vault.on("create", () => this.render())); this.registerEvent(this.app.vault.on("delete", () => this.render())); this.registerEvent(this.app.vault.on("rename", () => this.render())); this.registerEvent(this.app.vault.on("modify", (f) => { if (this.projectPath && f.path.startsWith(this.projectPath + "/")) this.render(); })); } async onClose(): Promise {} private async render(): Promise { const token = ++this.renderToken; const root = this.containerEl.children[1] as HTMLElement; if (!this.projectPath) { root.empty(); root.addClass("pk-root"); emptyState(root, "Kein Projekt ausgewählt."); return; } const projRoot = projectFolder(this.projectPath); const corePath = normalizePath(`${projRoot}/${CORE_FILE}`); const descPath = normalizePath(`${projRoot}/${DESCRIPTION_FILE}`); const subProjectsList = listSubProjects(this.app, this.projectPath); const subCorePaths = subProjectsList.map((s) => normalizePath(`${s.path}/${CORE_FILE}`)); const [core, desc, ...subCores] = await Promise.all([ readFile(this.app, corePath), readFile(this.app, descPath), ...subCorePaths.map((p) => readFile(this.app, p)), ]); if (token !== this.renderToken) return; root.empty(); root.addClass("pk-root"); this.renderBreadcrumb(root); const infoCards: Array<{ content: string; path: string }> = []; if (core.trim()) infoCards.push({ content: core, path: corePath }); if (desc.trim()) infoCards.push({ content: desc, path: descPath }); if (infoCards.length > 0) { const info = root.createDiv({ cls: "pk-info-grid" }); for (const ic of infoCards) this.renderInfoCard(info, ic.content, ic.path); } this.renderChildren(root, subProjectsList, subCorePaths, subCores); } private renderBreadcrumb(parent: HTMLElement): void { const chain = this.projectPath.split("/"); const segments: BreadcrumbSegment[] = [ { label: "Projekte", onClick: () => this.openProjectView() }, ]; for (let i = 0; i < chain.length; i++) { const path = chain.slice(0, i + 1).join("/"); const isLast = i === chain.length - 1; segments.push({ label: chain[i], onClick: isLast ? undefined : () => this.openProjectDetails(path), }); } breadcrumb(parent, segments); } private renderInfoCard(parent: HTMLElement, content: string, path: string): void { const btn = parent.createDiv({ cls: "pk-btn-card pk-info-card", attr: { role: "button", tabindex: "0" }, }); btn.addEventListener("click", () => openMarkdown(this.app, path, this.leaf)); void MarkdownRenderer.render(this.app, content, btn, path, this); } private renderChildren( parent: HTMLElement, subProjects: TFolder[], subCorePaths: string[], subCores: string[], ): void { const collections = listCollections(this.app, this.projectPath); const features = listProjectFeatures(this.app, this.projectPath); const section = parent.createDiv({ cls: "pk-areas-section" }); const flex = section.createDiv({ cls: "pk-areas-flex" }); flex.addEventListener("contextmenu", (ev) => { if (ev.defaultPrevented) return; ev.preventDefault(); this.openProjectLevelMenu(ev); }); flex.addEventListener("dragover", (ev) => { if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return; ev.preventDefault(); ev.dataTransfer.dropEffect = "move"; flex.addClass("pk-drop-zone"); }); flex.addEventListener("dragleave", (ev) => { const next = ev.relatedTarget as Node | null; if (next && flex.contains(next)) return; flex.removeClass("pk-drop-zone"); }); flex.addEventListener("drop", async (ev) => { flex.removeClass("pk-drop-zone"); const raw = ev.dataTransfer?.getData(PK_DND_MIME); if (!raw) return; ev.preventDefault(); await this.handleDropOnProjectRoot(raw); }); const takenChildren = [ ...subProjects.map((f) => f.name), ...collections.map((f) => f.name), ]; for (let i = 0; i < subProjects.length; i++) { this.renderSubProjectCard(flex, subProjects[i], takenChildren, subCores[i] ?? "", subCorePaths[i] ?? ""); } for (const col of collections) { this.renderCollectionCard(flex, col, takenChildren); } for (const f of features) { this.renderProjectFeatureCard(flex, f); } if (subProjects.length === 0 && collections.length === 0 && features.length === 0) { flex.createDiv({ cls: "pk-zone-placeholder", text: "Keine Inhalte. Rechtsklick fügt Sub-Projekt, Collection oder Feature hinzu." }); } } private openProjectLevelMenu(ev: MouseEvent): void { menu(ev, [ { title: "Neues Sub-Projekt", icon: "plus", onClick: () => this.openCreateSubProject() }, { title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection() }, { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() }, ]); } private renderSubProjectCard( parent: HTMLElement, sub: TFolder, takenChildren: string[], coreContent: string, corePath: string, ): void { const card = parent.createDiv({ cls: "pk-btn-card pk-area-card", attr: { role: "button", tabindex: "0" }, }); card.createEl("strong", { text: sub.name }); const trimmed = coreContent.trim(); if (trimmed) { const body = card.createDiv({ cls: "pk-project-core" }); void MarkdownRenderer.render(this.app, coreContent, body, corePath, this); } card.addEventListener("click", () => this.openProjectDetails(sub.path)); card.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); menu(ev, [ { title: "Neues Sub-Projekt", icon: "plus", onClick: () => this.openCreateSubProject() }, { title: "Sub-Projekt umbenennen", icon: "pencil", onClick: () => this.openRenameChildFolder(sub, takenChildren) }, { title: "Sub-Projekt löschen", icon: "trash", onClick: () => this.openDeletePath(sub.path) }, ]); }); } private renderCollectionCard( parent: HTMLElement, collection: TFolder, takenChildren: string[], ): void { const folderPath = collection.path; const features = listMarkdownFiles(this.app, folderPath, []); const card = parent.createDiv({ cls: "pk-btn-card pk-area-card", attr: { role: "button", tabindex: "0" }, }); card.createEl("strong", { text: collection.name }); card.addEventListener("click", () => this.openCollectionDetails(collection.name)); card.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); menu(ev, [ { title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection() }, { title: "Collection umbenennen", icon: "pencil", onClick: () => this.openRenameChildFolder(collection, takenChildren) }, { title: "Collection löschen", icon: "trash", onClick: () => this.openDeletePath(folderPath) }, ]); }); card.addEventListener("dragover", (ev) => { if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return; ev.preventDefault(); ev.dataTransfer.dropEffect = "move"; card.addClass("pk-drop-target"); }); card.addEventListener("dragleave", (ev) => { const next = ev.relatedTarget as Node | null; if (next && card.contains(next)) return; card.removeClass("pk-drop-target"); }); card.addEventListener("drop", async (ev) => { card.removeClass("pk-drop-target"); const raw = ev.dataTransfer?.getData(PK_DND_MIME); if (!raw) return; ev.preventDefault(); ev.stopPropagation(); await this.handleDropOnCollection(raw, folderPath); }); if (features.length === 0) { card.createDiv({ cls: "pk-empty", text: "Keine Features" }); } else { const list = card.createDiv({ cls: "pk-features" }); for (const f of features) { const chip = list.createDiv({ cls: "pk-feature-chip", text: f.basename }); chip.draggable = true; chip.addEventListener("dragstart", (ev) => { ev.stopPropagation(); if (!ev.dataTransfer) return; ev.dataTransfer.setData(PK_DND_MIME, JSON.stringify({ kind: "feature", sourcePath: f.path, name: f.basename, } satisfies DndPayload)); ev.dataTransfer.effectAllowed = "move"; chip.addClass("pk-feature-chip-dragging"); }); chip.addEventListener("dragend", () => chip.removeClass("pk-feature-chip-dragging")); chip.addEventListener("click", (ev) => { ev.stopPropagation(); openMarkdown(this.app, f.path, this.leaf); }); chip.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); menu(ev, [ { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeatureIn(folderPath) }, { title: "Feature löschen", icon: "trash", onClick: () => this.openDeletePath(f.path) }, ]); }); } } } private renderProjectFeatureCard(parent: HTMLElement, file: TFile): void { const card = parent.createDiv({ cls: "pk-btn-card pk-area-card", attr: { role: "button", tabindex: "0", draggable: "true" }, }); card.createEl("strong", { text: file.basename }); card.addEventListener("dragstart", (ev) => { if (!ev.dataTransfer) return; ev.dataTransfer.setData(PK_DND_MIME, JSON.stringify({ kind: "feature", sourcePath: file.path, name: file.basename, } satisfies DndPayload)); ev.dataTransfer.effectAllowed = "move"; card.addClass("pk-feature-chip-dragging"); }); card.addEventListener("dragend", () => card.removeClass("pk-feature-chip-dragging")); card.addEventListener("click", () => openMarkdown(this.app, file.path, this.leaf)); card.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); menu(ev, [ { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() }, { title: "Feature löschen", icon: "trash", onClick: () => this.openDeletePath(file.path) }, ]); }); } private parseDnd(raw: string): DndPayload | null { try { const data = JSON.parse(raw); if (!data?.sourcePath || !data?.name) return null; if (data.kind !== "feature") return null; return data as DndPayload; } catch { return null; } } private async handleDropOnProjectRoot(raw: string): Promise { const data = this.parseDnd(raw); if (!data) return; const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`; const newPath = normalizePath(`${this.projectPath}/${file}`); if (data.sourcePath === newPath) return; await this.movePath(data.sourcePath, newPath); } private async handleDropOnCollection(raw: string, collectionFolderPath: string): Promise { const data = this.parseDnd(raw); if (!data) return; const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`; const newPath = normalizePath(`${collectionFolderPath}/${file}`); if (data.sourcePath === newPath) return; await this.movePath(data.sourcePath, newPath); } private async movePath(oldPath: string, newPath: string): Promise { if (this.app.vault.getAbstractFileByPath(newPath)) { new Notice(`„${newPath}" existiert bereits.`); return; } try { await rename(this.app, oldPath, newPath); } catch (e) { new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`); return; } await this.render(); } private openCreateProjectFeature(): void { const taken = listProjectFeatures(this.app, this.projectPath).map((f) => f.basename); new NameModal(this.app, { title: "Neues Feature", label: "Feature-Name", cta: "Erstellen", validate: (n) => validateName(n, taken), onSubmit: async (name) => { await createProjectFeature(this.app, this.projectPath, name); await this.render(); }, }).open(); } private openCreateCollection(): void { const taken = [ ...listCollections(this.app, this.projectPath).map((f) => f.name), ...listSubProjects(this.app, this.projectPath).map((f) => f.name), ]; new NameModal(this.app, { title: "Neue Collection", label: "Collection-Name", cta: "Erstellen", validate: (n) => validateName(n, taken), onSubmit: async (name) => { await createCollection(this.app, this.projectPath, name); await this.render(); }, }).open(); } private openCreateSubProject(): void { const taken = [ ...listSubProjects(this.app, this.projectPath).map((f) => f.name), ...listCollections(this.app, this.projectPath).map((f) => f.name), ]; new NameModal(this.app, { title: "Neues Sub-Projekt", label: "Projektname", cta: "Erstellen", validate: (n) => validateName(n, taken), onSubmit: async (name) => { await createProject(this.app, subProjectPath(this.projectPath, name)); await this.render(); }, }).open(); } private openCreateFeatureIn(folderPath: string): void { const taken = listMarkdownFiles(this.app, folderPath, []).map((f) => f.basename); new NameModal(this.app, { title: "Neues Feature", label: "Feature-Name", cta: "Erstellen", validate: (n) => validateName(n, taken), onSubmit: async (name) => { const file = name.endsWith(".md") ? name : `${name}.md`; const path = normalizePath(`${folderPath}/${file}`); const existing = this.app.vault.getAbstractFileByPath(path); if (!existing) await this.app.vault.create(path, ""); await this.render(); }, }).open(); } private openRenameChildFolder(folder: TFolder, taken: string[]): void { const current = folder.name; new NameModal(this.app, { title: "Umbenennen", label: "Name", initial: current, cta: "Speichern", validate: (n) => validateName(n, taken, current), onSubmit: async (name) => { if (name === current) return; const parent = folder.path.substring(0, folder.path.lastIndexOf("/")); const newPath = parent ? normalizePath(`${parent}/${name}`) : normalizePath(name); await rename(this.app, folder.path, newPath); await this.render(); }, }).open(); } private async openDeletePath(path: string): Promise { await deleteRecursive(this.app, path); await this.render(); } private async openCollectionDetails(collection: string): Promise { await this.leaf.setViewState({ type: VIEW_TYPE_COLLECTION_VIEW, active: true, state: { projectPath: this.projectPath, collection }, }); } private async openProjectDetails(projectPath: string): Promise { await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_DETAILS_VIEW, active: true, state: { projectPath }, }); } private async openProjectView(): Promise { await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true }); } }