515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
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<string, unknown> {
|
|
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<void> {
|
|
this.projectPath = state?.projectPath ?? "";
|
|
await super.setState(state, result);
|
|
await this.render();
|
|
}
|
|
|
|
getState(): ProjectDetailsState {
|
|
return { projectPath: this.projectPath };
|
|
}
|
|
|
|
async onOpen(): Promise<void> {
|
|
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<void> {}
|
|
|
|
private async render(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<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 = 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<void> {
|
|
await deleteRecursive(this.app, path);
|
|
await this.render();
|
|
}
|
|
|
|
private async openCollectionDetails(collection: string): Promise<void> {
|
|
await this.leaf.setViewState({
|
|
type: VIEW_TYPE_COLLECTION_VIEW,
|
|
active: true,
|
|
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 },
|
|
});
|
|
}
|
|
|
|
private async openProjectView(): Promise<void> {
|
|
await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true });
|
|
}
|
|
}
|