This commit is contained in:
Team3
2026-05-19 22:02:32 +02:00
parent b98a998689
commit 2e8bf9599e
7 changed files with 317 additions and 259 deletions

View File

@@ -13,35 +13,36 @@ import {
VIEW_TYPE_COLLECTION_VIEW,
VIEW_TYPE_PROJECT_VIEW,
RIBBON_ICON,
PROJECT_FILES,
CORE_FILE,
TARGET_FILE,
DESCRIPTION_FILE,
} from "../const";
import {
Zone,
projectPath,
zoneRootPath,
listFolders,
projectFolder,
subProjectPath,
collectionPath,
listSubProjects,
listCollections,
listProjectFeatures,
listMarkdownFiles,
readFile,
rename,
deleteRecursive,
ensureFolder,
createCollection,
createProject,
createProjectFeature,
} from "../fs";
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
import { menu, breadcrumb, emptyState, openMarkdown, BreadcrumbSegment } from "../ui";
import { NameModal } from "../modals/NameModal";
export interface ProjectDetailsState extends Record<string, unknown> {
project: string;
projectPath: string;
}
const NAME_RX = /^[^\\/:*?"<>|]+$/;
const PK_DND_MIME = "application/x-pk-item";
interface DndPayload {
kind: "feature" | "collection";
kind: "feature";
sourcePath: string;
name: string;
}
@@ -54,7 +55,7 @@ function validateName(name: string, taken: string[], current?: string): string |
}
export class ProjectDetailsView extends ItemView {
project = "";
projectPath = "";
private renderToken = 0;
constructor(leaf: WorkspaceLeaf) {
@@ -67,7 +68,9 @@ export class ProjectDetailsView extends ItemView {
}
getDisplayText(): string {
return this.project ? `Projekt: ${this.project}` : "Projekt";
if (!this.projectPath) return "Projekt";
const segs = this.projectPath.split("/");
return `Projekt: ${segs[segs.length - 1]}`;
}
getIcon(): string {
@@ -75,13 +78,13 @@ export class ProjectDetailsView extends ItemView {
}
async setState(state: ProjectDetailsState, result: ViewStateResult): Promise<void> {
this.project = state?.project ?? "";
this.projectPath = state?.projectPath ?? "";
await super.setState(state, result);
await this.render();
}
getState(): ProjectDetailsState {
return { project: this.project };
return { projectPath: this.projectPath };
}
async onOpen(): Promise<void> {
@@ -90,7 +93,7 @@ export class ProjectDetailsView extends ItemView {
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 (f.path.startsWith(projectPath(this.project) + "/")) this.render();
if (this.projectPath && f.path.startsWith(this.projectPath + "/")) this.render();
}));
}
@@ -100,34 +103,55 @@ export class ProjectDetailsView extends ItemView {
const token = ++this.renderToken;
const root = this.containerEl.children[1] as HTMLElement;
if (!this.project) {
if (!this.projectPath) {
root.empty();
root.addClass("pk-root");
emptyState(root, "Kein Projekt ausgewählt.");
return;
}
const projRoot = projectPath(this.project);
const projRoot = projectFolder(this.projectPath);
const corePath = normalizePath(`${projRoot}/${CORE_FILE}`);
const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`);
const [core, target] = await Promise.all([
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, targetPath),
readFile(this.app, descPath),
...subCorePaths.map((p) => readFile(this.app, p)),
]);
if (token !== this.renderToken) return;
root.empty();
root.addClass("pk-root");
breadcrumb(root, [
{ label: "Projekte", onClick: () => this.openProjectView() },
{ label: this.project },
]);
this.renderBreadcrumb(root);
const info = root.createDiv({ cls: "pk-info-grid" });
this.renderInfoCard(info, core, corePath);
this.renderInfoCard(info, target, targetPath);
this.renderCollections(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 {
@@ -136,60 +160,25 @@ export class ProjectDetailsView extends ItemView {
attr: { role: "button", tabindex: "0" },
});
btn.addEventListener("click", () => openMarkdown(this.app, path, this.leaf));
if (content.trim()) {
void MarkdownRenderer.render(this.app, content, btn, path, this);
} else {
btn.setText("(leer)");
}
void MarkdownRenderer.render(this.app, content, btn, path, this);
}
private renderCollections(parent: HTMLElement): void {
const section = parent.createDiv({ cls: "pk-areas-section" });
const ready = this.collectZoneItems("ready");
const toUpdate = this.collectZoneItems("to-update");
const ideas = this.collectZoneItems("ideas");
this.renderZone(section, "ready", ready);
section.createEl("hr", { cls: "pk-zone-divider" });
this.renderZone(section, "to-update", toUpdate);
section.createEl("hr", { cls: "pk-zone-divider" });
this.renderZone(section, "ideas", ideas);
}
private collectZoneItems(zone: Zone): { collections: TFolder[]; features: TFile[] } {
const root = zoneRootPath(this.project, zone);
const folders = listFolders(this.app, root);
const features = listMarkdownFiles(
this.app,
root,
zone === "ready" ? [...PROJECT_FILES] : [],
);
return { collections: folders, features };
}
private zoneEmptyText(zone: Zone): string {
if (zone === "ready") return "Keine fertigen Features";
if (zone === "to-update") return "Keine unfertigen Features";
return "Keine neuen Features";
}
private renderZone(
private renderChildren(
parent: HTMLElement,
zone: Zone,
items: { collections: TFolder[]; features: TFile[] },
subProjects: TFolder[],
subCorePaths: string[],
subCores: string[],
): void {
const flex = parent.createDiv({
cls: `pk-areas-flex pk-zone-${zone}`,
attr: { "data-zone": zone },
});
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();
menu(ev, [
{ title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection(zone) },
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature(zone) },
]);
this.openProjectLevelMenu(ev);
});
flex.addEventListener("dragover", (ev) => {
if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return;
@@ -207,56 +196,88 @@ export class ProjectDetailsView extends ItemView {
const raw = ev.dataTransfer?.getData(PK_DND_MIME);
if (!raw) return;
ev.preventDefault();
await this.handleDropOnZone(raw, zone);
await this.handleDropOnProjectRoot(raw);
});
const takenCollections = items.collections.map((a) => a.name);
for (const collection of items.collections) {
this.renderCollectionCard(flex, zone, collection, takenCollections);
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 f of items.features) {
this.renderProjectFeatureCard(flex, zone, f);
for (const col of collections) {
this.renderCollectionCard(flex, col, takenChildren);
}
if (items.collections.length === 0 && items.features.length === 0) {
flex.createDiv({ cls: "pk-zone-placeholder", text: this.zoneEmptyText(zone) });
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,
zone: Zone,
collection: TFolder,
takenCollections: string[],
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", draggable: "true" },
attr: { role: "button", tabindex: "0" },
});
card.createEl("strong", { text: collection.name });
card.addEventListener("click", () => this.openCollectionDetails(collection.name, zone));
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(zone) },
{ title: "Collection umbenennen", icon: "pencil", onClick: () => this.openRenameCollectionAt(folderPath, collection.name, takenCollections) },
{ 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("dragstart", (ev) => {
if (!ev.dataTransfer) return;
ev.dataTransfer.setData(PK_DND_MIME, JSON.stringify({
kind: "collection",
sourcePath: folderPath,
name: collection.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();
@@ -312,7 +333,7 @@ export class ProjectDetailsView extends ItemView {
}
}
private renderProjectFeatureCard(parent: HTMLElement, zone: Zone, file: TFile): void {
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" },
@@ -334,7 +355,7 @@ export class ProjectDetailsView extends ItemView {
ev.preventDefault();
ev.stopPropagation();
menu(ev, [
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature(zone) },
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() },
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeletePath(file.path) },
]);
});
@@ -344,30 +365,25 @@ export class ProjectDetailsView extends ItemView {
try {
const data = JSON.parse(raw);
if (!data?.sourcePath || !data?.name) return null;
if (data.kind !== "feature" && data.kind !== "collection") return null;
if (data.kind !== "feature") return null;
return data as DndPayload;
} catch {
return null;
}
}
private async handleDropOnZone(raw: string, zone: Zone): Promise<void> {
private async handleDropOnProjectRoot(raw: string): 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}`);
const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`;
const newPath = normalizePath(`${this.projectPath}/${file}`);
if (data.sourcePath === newPath) return;
if (zone !== "ready") await ensureFolder(this.app, root);
await this.movePath(data.sourcePath, newPath);
}
private async handleDropOnCollection(raw: string, collectionFolderPath: 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(`${collectionFolderPath}/${file}`);
if (data.sourcePath === newPath) return;
@@ -388,38 +404,49 @@ export class ProjectDetailsView extends ItemView {
await this.render();
}
private openCreateProjectFeature(zone: Zone = "to-update"): void {
const root = zoneRootPath(this.project, zone);
const existing = listMarkdownFiles(
this.app,
root,
zone === "ready" ? [...PROJECT_FILES] : [],
);
const taken = existing.map((f) => f.basename);
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.project, name, zone);
await createProjectFeature(this.app, this.projectPath, name);
await this.render();
},
}).open();
}
private openCreateCollection(zone: Zone = "to-update"): void {
const allZones: Zone[] = ["ready", "to-update", "ideas"];
const taken = allZones.flatMap((z) =>
listFolders(this.app, zoneRootPath(this.project, z)).map((a) => a.name),
);
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.project, name, zone);
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();
@@ -442,17 +469,19 @@ export class ProjectDetailsView extends ItemView {
}).open();
}
private openRenameCollectionAt(folderPath: string, current: string, taken: string[]): void {
private openRenameChildFolder(folder: TFolder, taken: string[]): void {
const current = folder.name;
new NameModal(this.app, {
title: "Collection umbenennen",
label: "Collection-Name",
title: "Umbenennen",
label: "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}`));
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();
@@ -463,11 +492,19 @@ export class ProjectDetailsView extends ItemView {
await this.render();
}
private async openCollectionDetails(collection: string, zone: Zone): Promise<void> {
private async openCollectionDetails(collection: string): Promise<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_COLLECTION_VIEW,
active: true,
state: { project: this.project, collection, zone },
state: { projectPath: this.projectPath, collection },
});
}
private async openProjectDetails(projectPath: string): Promise<void> {
await this.leaf.setViewState({
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
active: true,
state: { projectPath },
});
}