Update
This commit is contained in:
477
src/views/ProjectDetailsView.ts
Normal file
477
src/views/ProjectDetailsView.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
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,
|
||||
PROJECT_FILES,
|
||||
CORE_FILE,
|
||||
TARGET_FILE,
|
||||
TO_UPDATE_DIR,
|
||||
} from "../const";
|
||||
import {
|
||||
Zone,
|
||||
projectPath,
|
||||
toUpdatePath,
|
||||
zoneRootPath,
|
||||
listFolders,
|
||||
listMarkdownFiles,
|
||||
readFile,
|
||||
rename,
|
||||
deleteRecursive,
|
||||
ensureFolder,
|
||||
createCollection,
|
||||
createProjectFeature,
|
||||
} from "../fs";
|
||||
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
|
||||
import { NameModal } from "../modals/NameModal";
|
||||
|
||||
export interface ProjectDetailsState extends Record<string, unknown> {
|
||||
project: string;
|
||||
}
|
||||
|
||||
const NAME_RX = /^[^\\/:*?"<>|]+$/;
|
||||
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.";
|
||||
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 {
|
||||
project = "";
|
||||
private renderToken = 0;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
this.navigation = true;
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return VIEW_TYPE_PROJECT_DETAILS_VIEW;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return this.project ? `Projekt: ${this.project}` : "Projekt";
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return RIBBON_ICON;
|
||||
}
|
||||
|
||||
async setState(state: ProjectDetailsState, result: ViewStateResult): Promise<void> {
|
||||
this.project = state?.project ?? "";
|
||||
await super.setState(state, result);
|
||||
await this.render();
|
||||
}
|
||||
|
||||
getState(): ProjectDetailsState {
|
||||
return { project: this.project };
|
||||
}
|
||||
|
||||
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 (f.path.startsWith(projectPath(this.project) + "/")) 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.project) {
|
||||
root.empty();
|
||||
root.addClass("pk-root");
|
||||
emptyState(root, "Kein Projekt ausgewählt.");
|
||||
return;
|
||||
}
|
||||
|
||||
const projRoot = projectPath(this.project);
|
||||
const corePath = normalizePath(`${projRoot}/${CORE_FILE}`);
|
||||
const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`);
|
||||
const [core, target] = await Promise.all([
|
||||
readFile(this.app, corePath),
|
||||
readFile(this.app, targetPath),
|
||||
]);
|
||||
if (token !== this.renderToken) return;
|
||||
|
||||
root.empty();
|
||||
root.addClass("pk-root");
|
||||
|
||||
breadcrumb(root, [
|
||||
{ label: "Projekte", onClick: () => this.openProjectView() },
|
||||
{ label: this.project },
|
||||
]);
|
||||
|
||||
const info = root.createDiv({ cls: "pk-info-grid" });
|
||||
this.renderInfoCard(info, core, corePath);
|
||||
this.renderInfoCard(info, target, targetPath);
|
||||
this.renderCollections(root);
|
||||
}
|
||||
|
||||
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));
|
||||
if (content.trim()) {
|
||||
void MarkdownRenderer.render(this.app, content, btn, path, this);
|
||||
} else {
|
||||
btn.setText("(leer)");
|
||||
}
|
||||
}
|
||||
|
||||
private renderCollections(parent: HTMLElement): void {
|
||||
const section = parent.createDiv({ cls: "pk-areas-section" });
|
||||
section.addEventListener("contextmenu", (ev) => {
|
||||
if (ev.defaultPrevented) return;
|
||||
ev.preventDefault();
|
||||
menu(ev, [
|
||||
{ title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection() },
|
||||
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() },
|
||||
]);
|
||||
});
|
||||
|
||||
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): { collections: TFolder[]; features: TFile[] } {
|
||||
const root = zoneRootPath(this.project, zone);
|
||||
const folders = listFolders(this.app, root);
|
||||
const collections = zone === "ready"
|
||||
? folders.filter((f) => f.name !== TO_UPDATE_DIR)
|
||||
: folders;
|
||||
const features = listMarkdownFiles(
|
||||
this.app,
|
||||
root,
|
||||
zone === "ready" ? [...PROJECT_FILES] : [],
|
||||
);
|
||||
return { collections, features };
|
||||
}
|
||||
|
||||
private renderZone(
|
||||
parent: HTMLElement,
|
||||
zone: Zone,
|
||||
items: { collections: 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";
|
||||
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.handleDropOnZone(raw, zone);
|
||||
});
|
||||
|
||||
const takenCollections = items.collections.map((a) => a.name);
|
||||
for (const collection of items.collections) {
|
||||
this.renderCollectionCard(flex, zone, collection, takenCollections);
|
||||
}
|
||||
for (const f of items.features) {
|
||||
this.renderProjectFeatureCard(flex, zone, f);
|
||||
}
|
||||
if (items.collections.length === 0 && items.features.length === 0) {
|
||||
flex.createDiv({ cls: "pk-zone-placeholder", text: "Keine Features" });
|
||||
}
|
||||
}
|
||||
|
||||
private renderCollectionCard(
|
||||
parent: HTMLElement,
|
||||
zone: Zone,
|
||||
collection: TFolder,
|
||||
takenCollections: 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" },
|
||||
});
|
||||
card.createEl("strong", { text: collection.name });
|
||||
card.addEventListener("click", () => this.openCollectionDetails(collection.name, zone));
|
||||
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.openRenameCollectionAt(folderPath, collection.name, takenCollections) },
|
||||
{ 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();
|
||||
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, zone: Zone, 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.openCreateProjectFeatureInZone(zone) },
|
||||
{ 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 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;
|
||||
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 {
|
||||
this.openCreateProjectFeatureInZone("to-update");
|
||||
}
|
||||
|
||||
private openCreateProjectFeatureInZone(zone: Zone): void {
|
||||
const root = zoneRootPath(this.project, zone);
|
||||
const existing = listMarkdownFiles(
|
||||
this.app,
|
||||
root,
|
||||
zone === "ready" ? [...PROJECT_FILES] : [],
|
||||
);
|
||||
const taken = existing.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 this.render();
|
||||
},
|
||||
}).open();
|
||||
}
|
||||
|
||||
private openCreateCollection(): void {
|
||||
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 Collection",
|
||||
label: "Collection-Name",
|
||||
cta: "Erstellen",
|
||||
validate: (n) => validateName(n, taken),
|
||||
onSubmit: async (name) => {
|
||||
await createCollection(this.app, this.project, 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 openRenameCollectionAt(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 openCollectionDetails(collection: string, zone: Zone): Promise<void> {
|
||||
await this.leaf.setViewState({
|
||||
type: VIEW_TYPE_COLLECTION_VIEW,
|
||||
active: true,
|
||||
state: { project: this.project, collection, zone },
|
||||
});
|
||||
}
|
||||
|
||||
private async openProjectView(): Promise<void> {
|
||||
await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user