From 27f7d14d4fc73cde7e41a83e3ee8b3cddda3cc2e Mon Sep 17 00:00:00 2001 From: Marek Date: Sun, 3 May 2026 13:04:46 +0200 Subject: [PATCH] update --- doc/__/desktop.md | 9 - doc/__/mobile.md | 2 - doc/_core.md | 2 - doc/_target.md | 2 - doc/area.md | 4 - doc/breadcrumb.md | 7 - doc/core.md | 4 - doc/details/__.md | 3 - doc/details/createFeature.md | 3 - doc/details/deleteFeature.md | 3 - doc/details/features.md | 9 - doc/feature.md | 3 - doc/overview/__.md | 5 - doc/overview/areas.md | 20 -- doc/overview/core.md | 2 - doc/overview/createArea.md | 3 - doc/overview/createFeature.md | 3 - doc/overview/deleteArea.md | 1 - doc/overview/deleteFeature.md | 1 - doc/overview/dragAndDrop.md | 4 - doc/overview/renameArea.md | 3 - doc/overview/target.md | 2 - doc/project.md | 3 - doc/projects/__.md | 2 - doc/projects/createProject.md | 4 - doc/projects/deleteProject.md | 1 - doc/projects/listing.md | 9 - doc/projects/pluginIcon.md | 2 - doc/projects/renameProject.md | 3 - doc/story/__.md | 4 - doc/story/erstellen.md | 7 - doc/story/erweitern.md | 4 - doc/target.md | 10 - src/const.ts | 1 + src/fs.ts | 63 ++++- src/views/DetailsView.ts | 20 +- src/views/OverviewView.ts | 478 +++++++++++++++++++--------------- styles.css | 14 + 38 files changed, 341 insertions(+), 379 deletions(-) delete mode 100644 doc/__/desktop.md delete mode 100644 doc/__/mobile.md delete mode 100644 doc/_core.md delete mode 100644 doc/_target.md delete mode 100644 doc/area.md delete mode 100644 doc/breadcrumb.md delete mode 100644 doc/core.md delete mode 100644 doc/details/__.md delete mode 100644 doc/details/createFeature.md delete mode 100644 doc/details/deleteFeature.md delete mode 100644 doc/details/features.md delete mode 100644 doc/feature.md delete mode 100644 doc/overview/__.md delete mode 100644 doc/overview/areas.md delete mode 100644 doc/overview/core.md delete mode 100644 doc/overview/createArea.md delete mode 100644 doc/overview/createFeature.md delete mode 100644 doc/overview/deleteArea.md delete mode 100644 doc/overview/deleteFeature.md delete mode 100644 doc/overview/dragAndDrop.md delete mode 100644 doc/overview/renameArea.md delete mode 100644 doc/overview/target.md delete mode 100644 doc/project.md delete mode 100644 doc/projects/__.md delete mode 100644 doc/projects/createProject.md delete mode 100644 doc/projects/deleteProject.md delete mode 100644 doc/projects/listing.md delete mode 100644 doc/projects/pluginIcon.md delete mode 100644 doc/projects/renameProject.md delete mode 100644 doc/story/__.md delete mode 100644 doc/story/erstellen.md delete mode 100644 doc/story/erweitern.md delete mode 100644 doc/target.md diff --git a/doc/__/desktop.md b/doc/__/desktop.md deleted file mode 100644 index 371d4bf..0000000 --- a/doc/__/desktop.md +++ /dev/null @@ -1,9 +0,0 @@ -- links sind die übersichten - - projektübersicht - - projektdetails - - areadetails - - links bleibt im selben fenster -- rechts sind die md dateien -- md datei öffnen - - öffnen sich immer rechts beim nächstmöglichen fenster - - nicht vorhanden, dann wird ein neues geöffnet diff --git a/doc/__/mobile.md b/doc/__/mobile.md deleted file mode 100644 index 4706149..0000000 --- a/doc/__/mobile.md +++ /dev/null @@ -1,2 +0,0 @@ -- projektübersicht, projektdetails, areadetails und md dateien bleiben im selben fenster -- navigation über die breadcrumb diff --git a/doc/_core.md b/doc/_core.md deleted file mode 100644 index 7a612aa..0000000 --- a/doc/_core.md +++ /dev/null @@ -1,2 +0,0 @@ -**Obsidian Plugin** -Projektkontext managen \ No newline at end of file diff --git a/doc/_target.md b/doc/_target.md deleted file mode 100644 index a8c26d2..0000000 --- a/doc/_target.md +++ /dev/null @@ -1,2 +0,0 @@ -Für große Projekte geht der Kontext schnell verloren, sie werden unübersichtlich und schwer zu managen. -Das Plugin hilft Projekte kontextbezogen zu strukturieren, so lassen sich große Projekte wieder managen. \ No newline at end of file diff --git a/doc/area.md b/doc/area.md deleted file mode 100644 index cff17eb..0000000 --- a/doc/area.md +++ /dev/null @@ -1,4 +0,0 @@ -- sammlung von features -- Auflistung als Columns (aufstockend) -- wird gespeichert als `./{area}/` -- `__` sind allgemeine zum ordner diff --git a/doc/breadcrumb.md b/doc/breadcrumb.md deleted file mode 100644 index a4137e3..0000000 --- a/doc/breadcrumb.md +++ /dev/null @@ -1,7 +0,0 @@ -- steht ganz oben -- desktop - - Projekte > {projektname} - - Projekte > {projektname} > {areaname} -- mobile - - Projekte > {projektname} > {md-name} - - Projekte > {projektname} > {areaname} > {md-name} diff --git a/doc/core.md b/doc/core.md deleted file mode 100644 index ff9c001..0000000 --- a/doc/core.md +++ /dev/null @@ -1,4 +0,0 @@ -- beschreibt kompakt was das projekt ist -- Max 128 Zeichen -- wird als `./_core.md` gespeichert -- als md gerendert diff --git a/doc/details/__.md b/doc/details/__.md deleted file mode 100644 index 8de98d9..0000000 --- a/doc/details/__.md +++ /dev/null @@ -1,3 +0,0 @@ -- LMB auf area öffnet die areatdetails -- enthält details zur area -- layout: features \ No newline at end of file diff --git a/doc/details/createFeature.md b/doc/details/createFeature.md deleted file mode 100644 index 78f075a..0000000 --- a/doc/details/createFeature.md +++ /dev/null @@ -1,3 +0,0 @@ -- erstellt eine neue feature md datei im area ordner -- modal für den feature namen -- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/details/deleteFeature.md b/doc/details/deleteFeature.md deleted file mode 100644 index 78f075a..0000000 --- a/doc/details/deleteFeature.md +++ /dev/null @@ -1,3 +0,0 @@ -- erstellt eine neue feature md datei im area ordner -- modal für den feature namen -- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/details/features.md b/doc/details/features.md deleted file mode 100644 index de0d46d..0000000 --- a/doc/details/features.md +++ /dev/null @@ -1,9 +0,0 @@ -- alle features zum area als button-cards - - name ind bold - - inhalt als md gerendert dadrunter -- max width für cards ist hier 300px -- LMB auf feature öffnet die `{feature}.md` -- RMB auf feature container öffnet die feature optionen - - feature erstellen -- RMB auf feature öffnet die feature optionen - - feature löschen \ No newline at end of file diff --git a/doc/feature.md b/doc/feature.md deleted file mode 100644 index fed43ca..0000000 --- a/doc/feature.md +++ /dev/null @@ -1,3 +0,0 @@ -- beschreibt eine funktionalität der area -- wie ein git commit -- wird gespeichert als `./{area}/{feature}.md` \ No newline at end of file diff --git a/doc/overview/__.md b/doc/overview/__.md deleted file mode 100644 index b7af0ce..0000000 --- a/doc/overview/__.md +++ /dev/null @@ -1,5 +0,0 @@ -- LMB auf projekt öffnet die projektdetails -- enthält details zum projekt -- layout als grid responsiv - - core+target - - areas \ No newline at end of file diff --git a/doc/overview/areas.md b/doc/overview/areas.md deleted file mode 100644 index 4b76add..0000000 --- a/doc/overview/areas.md +++ /dev/null @@ -1,20 +0,0 @@ -- zwei varienten - - area mit features - - einzelnes feature -- beide sind button-cards -- area enthält features als flex liste -- featurename ist dateiname -- zuerst einzelne features verwenden -- areas, wenn 2 oder mehr features zusammen gehören -- RMB auf area container öffnet optionen zur area container - - area erstellen - - feature erstellen -- RMB auf area öffnet optionen zur area - - feature erstellen - - area umbenenen - - area löschen -- RMB auf feature öffnet die feature optionen - - feature löschen -- LMB auf feature öffnet die {feature}.md - - diff --git a/doc/overview/core.md b/doc/overview/core.md deleted file mode 100644 index 0beaf27..0000000 --- a/doc/overview/core.md +++ /dev/null @@ -1,2 +0,0 @@ -- inhalt aus `_core.md` -- LMB öffnet `_core.md` \ No newline at end of file diff --git a/doc/overview/createArea.md b/doc/overview/createArea.md deleted file mode 100644 index dc4c3f6..0000000 --- a/doc/overview/createArea.md +++ /dev/null @@ -1,3 +0,0 @@ -- erstellt eine neuen area ordner -- modal für den area namen -- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/overview/createFeature.md b/doc/overview/createFeature.md deleted file mode 100644 index 78f075a..0000000 --- a/doc/overview/createFeature.md +++ /dev/null @@ -1,3 +0,0 @@ -- erstellt eine neue feature md datei im area ordner -- modal für den feature namen -- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/overview/deleteArea.md b/doc/overview/deleteArea.md deleted file mode 100644 index 5277565..0000000 --- a/doc/overview/deleteArea.md +++ /dev/null @@ -1 +0,0 @@ -- entfernt area rekursiv diff --git a/doc/overview/deleteFeature.md b/doc/overview/deleteFeature.md deleted file mode 100644 index ad38ff7..0000000 --- a/doc/overview/deleteFeature.md +++ /dev/null @@ -1 +0,0 @@ -- entfernt feature \ No newline at end of file diff --git a/doc/overview/dragAndDrop.md b/doc/overview/dragAndDrop.md deleted file mode 100644 index 4dcd973..0000000 --- a/doc/overview/dragAndDrop.md +++ /dev/null @@ -1,4 +0,0 @@ -- featues kann man per d&d verschieben - - featue aus area -> area container - - featue aus area container -> area - - featue aus area -> area diff --git a/doc/overview/renameArea.md b/doc/overview/renameArea.md deleted file mode 100644 index 03aef57..0000000 --- a/doc/overview/renameArea.md +++ /dev/null @@ -1,3 +0,0 @@ -- ändert den ordnernamen zur area -- modal mit dem aktuellen area namen -- speichern und abbrechen buttons \ No newline at end of file diff --git a/doc/overview/target.md b/doc/overview/target.md deleted file mode 100644 index 2f78a3e..0000000 --- a/doc/overview/target.md +++ /dev/null @@ -1,2 +0,0 @@ -- inhalt aus `_target.md` -- LMB öffnet `_target.md` \ No newline at end of file diff --git a/doc/project.md b/doc/project.md deleted file mode 100644 index adad3e9..0000000 --- a/doc/project.md +++ /dev/null @@ -1,3 +0,0 @@ -- root ordner zum projekt -- ist abgeschlossen, also hat keine abhängigkeit -- ordnername ist der projektname diff --git a/doc/projects/__.md b/doc/projects/__.md deleted file mode 100644 index 69686b0..0000000 --- a/doc/projects/__.md +++ /dev/null @@ -1,2 +0,0 @@ -- enthält alle projekte -- LMB auf plugin icon öffnet die projektübersicht diff --git a/doc/projects/createProject.md b/doc/projects/createProject.md deleted file mode 100644 index 7a9693e..0000000 --- a/doc/projects/createProject.md +++ /dev/null @@ -1,4 +0,0 @@ -- erstellt ein neues projekt -- erstellt automatisch `_core.md`, `_target.md` und story area -- modal für den projektnamen -- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/projects/deleteProject.md b/doc/projects/deleteProject.md deleted file mode 100644 index 00e822b..0000000 --- a/doc/projects/deleteProject.md +++ /dev/null @@ -1 +0,0 @@ -- entfernt projekt rekursiv diff --git a/doc/projects/listing.md b/doc/projects/listing.md deleted file mode 100644 index d83963f..0000000 --- a/doc/projects/listing.md +++ /dev/null @@ -1,9 +0,0 @@ -- lädt alle ordner aus ./projects/ -- projekte als button-cards -- grid layout -- name ist ordnername bold und core inhalt dadrunter -- RMB auf projekt Container öffnet optionen zum projekt - - projekt erstellen -- RMB auf projekt öffnet optionen zum projekt - - projekt umbenennen - - projekt löschen \ No newline at end of file diff --git a/doc/projects/pluginIcon.md b/doc/projects/pluginIcon.md deleted file mode 100644 index 2d0579c..0000000 --- a/doc/projects/pluginIcon.md +++ /dev/null @@ -1,2 +0,0 @@ -- layout-grid icon -- icon links in obsidian navigation \ No newline at end of file diff --git a/doc/projects/renameProject.md b/doc/projects/renameProject.md deleted file mode 100644 index d507488..0000000 --- a/doc/projects/renameProject.md +++ /dev/null @@ -1,3 +0,0 @@ -- ändert den ordnernamen zum projekt -- modal mit dem aktuellen projektnamen -- speichern und abbrechen buttons \ No newline at end of file diff --git a/doc/story/__.md b/doc/story/__.md deleted file mode 100644 index f1ce7a2..0000000 --- a/doc/story/__.md +++ /dev/null @@ -1,4 +0,0 @@ -- beschreibt wie das fertige projekt in der regel verwendet werden soll -- abfolge von schritten (start ... ende) -- eine oder mehrere stories -- stehen in der area story \ No newline at end of file diff --git a/doc/story/erstellen.md b/doc/story/erstellen.md deleted file mode 100644 index 71bb8ba..0000000 --- a/doc/story/erstellen.md +++ /dev/null @@ -1,7 +0,0 @@ -- Großes Projekt soll geplannt werden -- Core definieren (Worum geht es?) -- Target beschreiben (Welches Problem löst es?) -- Story schreiben (Wie wird es verwendet?) -- Areas und Features definieren (Welche Funktionen hat es?) -- Features implementieren -- Projekt v1 \ No newline at end of file diff --git a/doc/story/erweitern.md b/doc/story/erweitern.md deleted file mode 100644 index cd7801c..0000000 --- a/doc/story/erweitern.md +++ /dev/null @@ -1,4 +0,0 @@ -- Projekt soll erweitert werden -- Areas und Features erstellen / anpassen / entfernen -- Features implementieren -- Projekt v2 \ No newline at end of file diff --git a/doc/target.md b/doc/target.md deleted file mode 100644 index ff05b55..0000000 --- a/doc/target.md +++ /dev/null @@ -1,10 +0,0 @@ -- beschreibt simpel welches problem das projekt löst -- Folgende Fragen werden beantwortet - - Was ist das Problem? - - Was ist die Folge vom Problem? - - Wie löst das Projekt das Problem? - - Was ist die folge der Lösung? -- der zweck muss klar erkennbar sein -- max 256 zeichen -- wird als `./_target.md` gespeichert -- als md gerendert diff --git a/src/const.ts b/src/const.ts index 594ab7a..2eaab46 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,4 +1,5 @@ export const PROJECTS_ROOT = "projects"; +export const TO_UPDATE_DIR = "_to-update"; export const VIEW_TYPE_PROJECTS = "projektkontext-projects"; export const VIEW_TYPE_OVERVIEW = "projektkontext-overview"; diff --git a/src/fs.ts b/src/fs.ts index 8f218c9..f0f5b9f 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,5 +1,7 @@ import { App, TFile, TFolder, normalizePath } from "obsidian"; -import { PROJECTS_ROOT, PROJECT_FILES } from "./const"; +import { PROJECTS_ROOT, PROJECT_FILES, TO_UPDATE_DIR } from "./const"; + +export type Zone = "ready" | "to-update"; export function projectsPath(): string { return PROJECTS_ROOT; @@ -9,13 +11,26 @@ export function projectPath(name: string): string { return normalizePath(`${PROJECTS_ROOT}/${name}`); } -export function areaPath(project: string, area: string): string { - return normalizePath(`${PROJECTS_ROOT}/${project}/${area}`); +export function toUpdatePath(project: string): string { + return normalizePath(`${PROJECTS_ROOT}/${project}/${TO_UPDATE_DIR}`); } -export function featurePath(project: string, area: string, feature: string): string { +export function zoneRootPath(project: string, zone: Zone): string { + return zone === "ready" ? projectPath(project) : toUpdatePath(project); +} + +export function areaPath(project: string, area: string, zone: Zone = "ready"): string { + return normalizePath(`${zoneRootPath(project, zone)}/${area}`); +} + +export function featurePath( + project: string, + area: string, + feature: string, + zone: Zone = "ready", +): string { const file = feature.endsWith(".md") ? feature : `${feature}.md`; - return normalizePath(`${PROJECTS_ROOT}/${project}/${area}/${file}`); + return normalizePath(`${zoneRootPath(project, zone)}/${area}/${file}`); } export async function ensureFolder(app: App, path: string): Promise { @@ -82,8 +97,14 @@ export async function createProject(app: App, name: string): Promise { await createArea(app, name, "story"); } -export async function createArea(app: App, project: string, area: string): Promise { - await ensureFolder(app, areaPath(project, area)); +export async function createArea( + app: App, + project: string, + area: string, + zone: Zone = "to-update", +): Promise { + if (zone === "to-update") await ensureFolder(app, toUpdatePath(project)); + await ensureFolder(app, areaPath(project, area, zone)); } export async function createFeature( @@ -91,21 +112,28 @@ export async function createFeature( project: string, area: string, feature: string, + zone: Zone = "ready", ): Promise { - return await ensureFile(app, featurePath(project, area, feature), ""); + return await ensureFile(app, featurePath(project, area, feature, zone), ""); } -export function projectFeaturePath(project: string, feature: string): string { +export function projectFeaturePath( + project: string, + feature: string, + zone: Zone = "ready", +): string { const file = feature.endsWith(".md") ? feature : `${feature}.md`; - return normalizePath(`${PROJECTS_ROOT}/${project}/${file}`); + return normalizePath(`${zoneRootPath(project, zone)}/${file}`); } export async function createProjectFeature( app: App, project: string, feature: string, + zone: Zone = "to-update", ): Promise { - return await ensureFile(app, projectFeaturePath(project, feature), ""); + if (zone === "to-update") await ensureFolder(app, toUpdatePath(project)); + return await ensureFile(app, projectFeaturePath(project, feature, zone), ""); } export function isProjectRootFile(name: string): boolean { @@ -115,13 +143,22 @@ export function isProjectRootFile(name: string): boolean { export interface ProjectFileLocation { project: string; area?: string; + zone: Zone; } export function parseProjectFilePath(path: string): ProjectFileLocation | null { if (!path.endsWith(".md")) return null; const parts = normalizePath(path).split("/"); if (parts[0] !== PROJECTS_ROOT) return null; - if (parts.length === 3) return { project: parts[1] }; - if (parts.length === 4) return { project: parts[1], area: parts[2] }; + if (parts.length < 3) return null; + const project = parts[1]; + const rest = parts.slice(2); + let zone: Zone = "ready"; + if (rest[0] === TO_UPDATE_DIR) { + zone = "to-update"; + rest.shift(); + } + if (rest.length === 1) return { project, zone }; + if (rest.length === 2) return { project, area: rest[0], zone }; return null; } diff --git a/src/views/DetailsView.ts b/src/views/DetailsView.ts index e81ee15..d8acb0e 100644 --- a/src/views/DetailsView.ts +++ b/src/views/DetailsView.ts @@ -6,6 +6,7 @@ import { RIBBON_ICON, } from "../const"; import { + Zone, areaPath, featurePath, listMarkdownFiles, @@ -19,6 +20,7 @@ import { NameModal } from "../modals/NameModal"; export interface DetailsState extends Record { project: string; area: string; + zone?: Zone; } const NAME_RX = /^[^\\/:*?"<>|]+$/; @@ -33,6 +35,7 @@ function validateName(name: string, taken: string[]): string | null { export class DetailsView extends ItemView { project = ""; area = ""; + zone: Zone = "ready"; private renderToken = 0; constructor(leaf: WorkspaceLeaf) { @@ -55,12 +58,13 @@ export class DetailsView extends ItemView { async setState(state: DetailsState, result: ViewStateResult): Promise { this.project = state?.project ?? ""; this.area = state?.area ?? ""; + this.zone = state?.zone ?? "ready"; await super.setState(state, result); await this.render(); } getState(): DetailsState { - return { project: this.project, area: this.area }; + return { project: this.project, area: this.area, zone: this.zone }; } async onOpen(): Promise { @@ -90,7 +94,7 @@ export class DetailsView extends ItemView { return; } - const features = listMarkdownFiles(this.app, areaPath(this.project, this.area), []); + const features = listMarkdownFiles(this.app, areaPath(this.project, this.area, this.zone), []); const contents = await Promise.all(features.map((f) => readFile(this.app, f.path))); if (token !== this.renderToken) return; @@ -133,23 +137,25 @@ export class DetailsView extends ItemView { } private openCreate(): void { - const taken = listMarkdownFiles(this.app, areaPath(this.project, this.area), []).map( - (f) => f.basename, - ); + const taken = listMarkdownFiles( + this.app, + areaPath(this.project, this.area, this.zone), + [], + ).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 createFeature(this.app, this.project, this.area, name); + await createFeature(this.app, this.project, this.area, name, this.zone); await this.render(); }, }).open(); } private async openDelete(feature: string): Promise { - await deleteRecursive(this.app, featurePath(this.project, this.area, feature)); + await deleteRecursive(this.app, featurePath(this.project, this.area, feature, this.zone)); await this.render(); } diff --git a/src/views/OverviewView.ts b/src/views/OverviewView.ts index 4fb6123..d331d38 100644 --- a/src/views/OverviewView.ts +++ b/src/views/OverviewView.ts @@ -1,4 +1,4 @@ -import { ItemView, MarkdownRenderer, Notice, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian"; +import { ItemView, MarkdownRenderer, Notice, TFile, TFolder, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian"; import { VIEW_TYPE_OVERVIEW, VIEW_TYPE_DETAILS, @@ -7,17 +7,19 @@ import { PROJECT_FILES, CORE_FILE, TARGET_FILE, + TO_UPDATE_DIR, } from "../const"; import { + Zone, projectPath, - areaPath, - featurePath, - projectFeaturePath, + toUpdatePath, + zoneRootPath, listFolders, listMarkdownFiles, readFile, rename, deleteRecursive, + ensureFolder, createArea, createFeature, createProjectFeature, @@ -30,7 +32,13 @@ export interface OverviewState extends Record { } const NAME_RX = /^[^\\/:*?"<>|]+$/; -const FEATURE_DND_MIME = "application/x-pk-feature"; +const PK_DND_MIME = "application/x-pk-item"; + +interface DndPayload { + kind: "feature" | "collection"; + sourcePath: string; + name: string; +} function validateName(name: string, taken: string[], current?: string): string | null { if (!name) return "Name darf nicht leer sein."; @@ -135,146 +143,243 @@ export class OverviewView extends ItemView { if (ev.defaultPrevented) return; ev.preventDefault(); menu(ev, [ - { title: "Neue Area", icon: "plus", onClick: () => this.openCreateArea() }, + { title: "Neue Collection", icon: "plus", onClick: () => this.openCreateArea() }, { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() }, ]); }); - section.addEventListener("dragover", (ev) => { - if (!ev.dataTransfer?.types.includes(FEATURE_DND_MIME)) return; + + const ready = this.collectZoneItems("ready"); + const toUpdate = this.collectZoneItems("to-update"); + + this.renderZone(section, "ready", ready); + section.createEl("hr", { cls: "pk-zone-divider" }); + this.renderZone(section, "to-update", toUpdate); + } + + private collectZoneItems(zone: Zone): { areas: TFolder[]; features: TFile[] } { + const root = zoneRootPath(this.project, zone); + const folders = listFolders(this.app, root); + const areas = zone === "ready" + ? folders.filter((f) => f.name !== TO_UPDATE_DIR) + : folders; + const features = listMarkdownFiles( + this.app, + root, + zone === "ready" ? [...PROJECT_FILES] : [], + ); + return { areas, features }; + } + + private renderZone( + parent: HTMLElement, + zone: Zone, + items: { areas: TFolder[]; features: TFile[] }, + ): void { + const flex = parent.createDiv({ + cls: `pk-areas-flex pk-zone-${zone}`, + attr: { "data-zone": zone }, + }); + flex.addEventListener("dragover", (ev) => { + if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return; ev.preventDefault(); ev.dataTransfer.dropEffect = "move"; - section.addClass("pk-drop-zone"); + flex.addClass("pk-drop-zone"); }); - section.addEventListener("dragleave", (ev) => { + flex.addEventListener("dragleave", (ev) => { const next = ev.relatedTarget as Node | null; - if (next && section.contains(next)) return; - section.removeClass("pk-drop-zone"); + if (next && flex.contains(next)) return; + flex.removeClass("pk-drop-zone"); }); - section.addEventListener("drop", async (ev) => { - section.removeClass("pk-drop-zone"); - const raw = ev.dataTransfer?.getData(FEATURE_DND_MIME); + 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.handleFeatureDropToProject(raw); + await this.handleDropOnZone(raw, zone); }); - const areas = listFolders(this.app, projectPath(this.project)); - const projectFeatures = listMarkdownFiles( - this.app, - projectPath(this.project), - [...PROJECT_FILES], - ); - if (areas.length === 0 && projectFeatures.length === 0) { - emptyState(section, "Noch leer. Rechtsklick → Neue Area / Neues Feature."); - return; + const takenAreas = items.areas.map((a) => a.name); + for (const area of items.areas) { + this.renderAreaCard(flex, zone, area, takenAreas); } - const takenAreas = areas.map((a) => a.name); - const stack = section.createDiv({ cls: "pk-areas-flex" }); - for (const area of areas) { - const features = listMarkdownFiles( - this.app, - areaPath(this.project, area.name), - [], - ); - const areaCard = stack.createDiv({ - cls: "pk-btn-card pk-area-card", - attr: { role: "button", tabindex: "0" }, - }); - areaCard.createEl("strong", { text: area.name }); - areaCard.addEventListener("click", () => this.openDetails(area.name)); - areaCard.addEventListener("contextmenu", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - menu(ev, [ - { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeature(area.name) }, - { title: "Area umbenennen", icon: "pencil", onClick: () => this.openRenameArea(area.name, takenAreas) }, - { title: "Area löschen", icon: "trash", onClick: () => this.openDeleteArea(area.name) }, - ]); - }); - areaCard.addEventListener("dragover", (ev) => { - if (!ev.dataTransfer?.types.includes(FEATURE_DND_MIME)) return; - ev.preventDefault(); - ev.dataTransfer.dropEffect = "move"; - areaCard.addClass("pk-drop-target"); - }); - areaCard.addEventListener("dragleave", (ev) => { - const next = ev.relatedTarget as Node | null; - if (next && areaCard.contains(next)) return; - areaCard.removeClass("pk-drop-target"); - }); - areaCard.addEventListener("drop", async (ev) => { - areaCard.removeClass("pk-drop-target"); - const raw = ev.dataTransfer?.getData(FEATURE_DND_MIME); - if (!raw) return; - ev.preventDefault(); - ev.stopPropagation(); - await this.handleFeatureDrop(raw, area.name); - }); - if (features.length === 0) { - areaCard.createDiv({ cls: "pk-empty", text: "Keine Features." }); - } else { - const list = areaCard.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) => { - if (!ev.dataTransfer) return; - ev.dataTransfer.setData( - FEATURE_DND_MIME, - JSON.stringify({ sourceArea: area.name, feature: f.basename }), - ); - 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: "Feature löschen", icon: "trash", onClick: () => this.openDeleteFeature(area.name, f.basename) }, - ]); - }); - } - } + for (const f of items.features) { + this.renderProjectFeatureCard(flex, f); } - - for (const f of projectFeatures) { - const fc = stack.createDiv({ - cls: "pk-btn-card pk-area-card", - attr: { role: "button", tabindex: "0", draggable: "true" }, - }); - fc.createEl("strong", { text: f.basename }); - fc.addEventListener("dragstart", (ev) => { - if (!ev.dataTransfer) return; - ev.dataTransfer.setData( - FEATURE_DND_MIME, - JSON.stringify({ sourceArea: null, feature: f.basename }), - ); - ev.dataTransfer.effectAllowed = "move"; - fc.addClass("pk-feature-chip-dragging"); - }); - fc.addEventListener("dragend", () => { - fc.removeClass("pk-feature-chip-dragging"); - }); - fc.addEventListener("click", () => openMarkdown(this.app, f.path, this.leaf)); - fc.addEventListener("contextmenu", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - menu(ev, [ - { title: "Feature löschen", icon: "trash", onClick: () => this.openDeleteProjectFeature(f.basename) }, - ]); + if (items.areas.length === 0 && items.features.length === 0) { + flex.createDiv({ + cls: "pk-zone-placeholder", + text: zone === "to-update" ? "Nothing to update" : "Empty", }); } } + private renderAreaCard( + parent: HTMLElement, + zone: Zone, + area: TFolder, + takenAreas: string[], + ): void { + const folderPath = area.path; + const features = listMarkdownFiles(this.app, folderPath, []); + + const card = parent.createDiv({ + cls: "pk-btn-card pk-area-card", + attr: { role: "button", tabindex: "0", draggable: "true" }, + }); + card.createEl("strong", { text: area.name }); + card.addEventListener("click", () => this.openDetails(area.name, zone)); + card.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + menu(ev, [ + { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeatureIn(folderPath) }, + { title: "Collection umbenennen", icon: "pencil", onClick: () => this.openRenameAreaAt(folderPath, area.name, takenAreas) }, + { title: "Collection löschen", icon: "trash", onClick: () => this.openDeletePath(folderPath) }, + ]); + }); + card.addEventListener("dragstart", (ev) => { + if (!ev.dataTransfer) return; + ev.dataTransfer.setData(PK_DND_MIME, JSON.stringify({ + kind: "collection", + sourcePath: folderPath, + name: area.name, + } satisfies DndPayload)); + ev.dataTransfer.effectAllowed = "move"; + card.addClass("pk-feature-chip-dragging"); + }); + card.addEventListener("dragend", () => card.removeClass("pk-feature-chip-dragging")); + 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.handleDropOnArea(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) => { + 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: "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: "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" && data.kind !== "collection") return null; + return data as DndPayload; + } catch { + return null; + } + } + + private async handleDropOnZone(raw: string, zone: Zone): Promise { + const data = this.parseDnd(raw); + if (!data) return; + const root = zoneRootPath(this.project, zone); + const targetName = data.kind === "feature" + ? (data.name.endsWith(".md") ? data.name : `${data.name}.md`) + : data.name; + const newPath = normalizePath(`${root}/${targetName}`); + if (data.sourcePath === newPath) return; + if (zone === "to-update") await ensureFolder(this.app, toUpdatePath(this.project)); + await this.movePath(data.sourcePath, newPath); + } + + private async handleDropOnArea(raw: string, areaFolderPath: string): Promise { + const data = this.parseDnd(raw); + if (!data) return; + if (data.kind !== "feature") return; + const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`; + const newPath = normalizePath(`${areaFolderPath}/${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 = listMarkdownFiles(this.app, projectPath(this.project), []).map((f) => f.basename); + const root = toUpdatePath(this.project); + const existing = listMarkdownFiles(this.app, root, []); + const taken = existing.map((f) => f.basename); new NameModal(this.app, { title: "Neues Feature", label: "Feature-Name", @@ -287,66 +392,14 @@ export class OverviewView extends ItemView { }).open(); } - private async openDeleteProjectFeature(feature: string): Promise { - await deleteRecursive(this.app, projectFeaturePath(this.project, feature)); - await this.render(); - } - - private async handleFeatureDropToProject(raw: string): Promise { - let data: { sourceArea: string | null; feature: string }; - try { - data = JSON.parse(raw); - } catch { - return; - } - if (!data?.feature) return; - if (data.sourceArea === null) return; - const newPath = projectFeaturePath(this.project, data.feature); - if (this.app.vault.getAbstractFileByPath(newPath)) { - new Notice(`„${data.feature}" existiert im Projekt bereits.`); - return; - } - const oldPath = featurePath(this.project, data.sourceArea, data.feature); - try { - await rename(this.app, oldPath, newPath); - } catch (e) { - new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`); - return; - } - await this.render(); - } - - private async handleFeatureDrop(raw: string, targetArea: string): Promise { - let data: { sourceArea: string | null; feature: string }; - try { - data = JSON.parse(raw); - } catch { - return; - } - if (!data?.feature) return; - if (data.sourceArea === targetArea) return; - const newPath = featurePath(this.project, targetArea, data.feature); - if (this.app.vault.getAbstractFileByPath(newPath)) { - new Notice(`„${data.feature}" existiert in „${targetArea}" bereits.`); - return; - } - const oldPath = data.sourceArea === null - ? projectFeaturePath(this.project, data.feature) - : featurePath(this.project, data.sourceArea, data.feature); - try { - await rename(this.app, oldPath, newPath); - } catch (e) { - new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`); - return; - } - await this.render(); - } - private openCreateArea(): void { - const taken = listFolders(this.app, projectPath(this.project)).map((a) => a.name); + const ready = listFolders(this.app, projectPath(this.project)) + .filter((f) => f.name !== TO_UPDATE_DIR).map((a) => a.name); + const inToUpdate = listFolders(this.app, toUpdatePath(this.project)).map((a) => a.name); + const taken = [...ready, ...inToUpdate]; new NameModal(this.app, { - title: "Neue Area", - label: "Area-Name", + title: "Neue Collection", + label: "Collection-Name", cta: "Erstellen", validate: (n) => validateName(n, taken), onSubmit: async (name) => { @@ -356,54 +409,49 @@ export class OverviewView extends ItemView { }).open(); } - private openRenameArea(current: string, taken: string[]): void { - new NameModal(this.app, { - title: "Area umbenennen", - label: "Area-Name", - initial: current, - cta: "Speichern", - validate: (n) => validateName(n, taken, current), - onSubmit: async (name) => { - if (name === current) return; - await rename( - this.app, - areaPath(this.project, current), - areaPath(this.project, name), - ); - await this.render(); - }, - }).open(); - } - - private async openDeleteArea(name: string): Promise { - await deleteRecursive(this.app, areaPath(this.project, name)); - await this.render(); - } - - private openCreateFeature(area: string): void { - const taken = listMarkdownFiles(this.app, areaPath(this.project, area), []).map((f) => f.basename); + 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) => { - await createFeature(this.app, this.project, area, 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 async openDeleteFeature(area: string, feature: string): Promise { - await deleteRecursive(this.app, featurePath(this.project, area, feature)); + private openRenameAreaAt(folderPath: string, current: string, taken: string[]): void { + new NameModal(this.app, { + title: "Collection umbenennen", + label: "Collection-Name", + initial: current, + cta: "Speichern", + validate: (n) => validateName(n, taken, current), + onSubmit: async (name) => { + if (name === current) return; + const parent = folderPath.substring(0, folderPath.lastIndexOf("/")); + await rename(this.app, folderPath, normalizePath(`${parent}/${name}`)); + await this.render(); + }, + }).open(); + } + + private async openDeletePath(path: string): Promise { + await deleteRecursive(this.app, path); await this.render(); } - private async openDetails(area: string): Promise { + private async openDetails(area: string, zone: Zone): Promise { await this.leaf.setViewState({ type: VIEW_TYPE_DETAILS, active: true, - state: { project: this.project, area }, + state: { project: this.project, area, zone }, }); } diff --git a/styles.css b/styles.css index 44f3b53..64a5603 100644 --- a/styles.css +++ b/styles.css @@ -230,9 +230,23 @@ .pk-areas-flex { column-width: 200px; column-gap: 5px; + min-height: 100px; +} + +.pk-zone-placeholder { + color: var(--text-muted); + text-align: center; + padding: 30px 12px; + pointer-events: none; } .pk-area-card { break-inside: avoid; margin-bottom: 5px; } + +.pk-zone-divider { + border: 0; + border-top: 1px solid var(--background-modifier-border); + margin: 4px 0; +}