This commit is contained in:
2026-05-03 13:04:46 +02:00
parent dddbdeb616
commit 27f7d14d4f
38 changed files with 341 additions and 379 deletions

View File

@@ -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

View File

@@ -1,2 +0,0 @@
- projektübersicht, projektdetails, areadetails und md dateien bleiben im selben fenster
- navigation über die breadcrumb

View File

@@ -1,2 +0,0 @@
**Obsidian Plugin**
Projektkontext managen

View File

@@ -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.

View File

@@ -1,4 +0,0 @@
- sammlung von features
- Auflistung als Columns (aufstockend)
- wird gespeichert als `./{area}/`
- `__` sind allgemeine zum ordner

View File

@@ -1,7 +0,0 @@
- steht ganz oben
- desktop
- Projekte > {projektname}
- Projekte > {projektname} > {areaname}
- mobile
- Projekte > {projektname} > {md-name}
- Projekte > {projektname} > {areaname} > {md-name}

View File

@@ -1,4 +0,0 @@
- beschreibt kompakt was das projekt ist
- Max 128 Zeichen
- wird als `./_core.md` gespeichert
- als md gerendert

View File

@@ -1,3 +0,0 @@
- LMB auf area öffnet die areatdetails
- enthält details zur area
- layout: features

View File

@@ -1,3 +0,0 @@
- erstellt eine neue feature md datei im area ordner
- modal für den feature namen
- erstellen und abbrechen buttons

View File

@@ -1,3 +0,0 @@
- erstellt eine neue feature md datei im area ordner
- modal für den feature namen
- erstellen und abbrechen buttons

View File

@@ -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

View File

@@ -1,3 +0,0 @@
- beschreibt eine funktionalität der area
- wie ein git commit
- wird gespeichert als `./{area}/{feature}.md`

View File

@@ -1,5 +0,0 @@
- LMB auf projekt öffnet die projektdetails
- enthält details zum projekt
- layout als grid responsiv
- core+target
- areas

View File

@@ -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

View File

@@ -1,2 +0,0 @@
- inhalt aus `_core.md`
- LMB öffnet `_core.md`

View File

@@ -1,3 +0,0 @@
- erstellt eine neuen area ordner
- modal für den area namen
- erstellen und abbrechen buttons

View File

@@ -1,3 +0,0 @@
- erstellt eine neue feature md datei im area ordner
- modal für den feature namen
- erstellen und abbrechen buttons

View File

@@ -1 +0,0 @@
- entfernt area rekursiv

View File

@@ -1 +0,0 @@
- entfernt feature

View File

@@ -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

View File

@@ -1,3 +0,0 @@
- ändert den ordnernamen zur area
- modal mit dem aktuellen area namen
- speichern und abbrechen buttons

View File

@@ -1,2 +0,0 @@
- inhalt aus `_target.md`
- LMB öffnet `_target.md`

View File

@@ -1,3 +0,0 @@
- root ordner zum projekt
- ist abgeschlossen, also hat keine abhängigkeit
- ordnername ist der projektname

View File

@@ -1,2 +0,0 @@
- enthält alle projekte
- LMB auf plugin icon öffnet die projektübersicht

View File

@@ -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

View File

@@ -1 +0,0 @@
- entfernt projekt rekursiv

View File

@@ -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

View File

@@ -1,2 +0,0 @@
- layout-grid icon
- icon links in obsidian navigation

View File

@@ -1,3 +0,0 @@
- ändert den ordnernamen zum projekt
- modal mit dem aktuellen projektnamen
- speichern und abbrechen buttons

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +0,0 @@
- Projekt soll erweitert werden
- Areas und Features erstellen / anpassen / entfernen
- Features implementieren
- Projekt v2

View File

@@ -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

View File

@@ -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";

View File

@@ -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<void> {
@@ -82,8 +97,14 @@ export async function createProject(app: App, name: string): Promise<void> {
await createArea(app, name, "story");
}
export async function createArea(app: App, project: string, area: string): Promise<void> {
await ensureFolder(app, areaPath(project, area));
export async function createArea(
app: App,
project: string,
area: string,
zone: Zone = "to-update",
): Promise<void> {
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<TFile> {
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<TFile> {
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;
}

View File

@@ -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<string, unknown> {
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<void> {
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<void> {
@@ -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<void> {
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();
}

View File

@@ -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<string, unknown> {
}
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<void> {
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<void> {
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<void> {
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<void> {
await deleteRecursive(this.app, projectFeaturePath(this.project, feature));
await this.render();
}
private async handleFeatureDropToProject(raw: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await deleteRecursive(this.app, path);
await this.render();
}
private async openDetails(area: string): Promise<void> {
private async openDetails(area: string, zone: Zone): Promise<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_DETAILS,
active: true,
state: { project: this.project, area },
state: { project: this.project, area, zone },
});
}

View File

@@ -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;
}