feat: implement plan step 1 — WebDAV CRUD (rename/copy/delete), content read/write; CodeMirror editor; UI actions; env(Qdrant); types & WebDAV client enhancements

This commit is contained in:
nicholai 2025-09-13 06:48:10 -06:00
parent 479e461430
commit 362a97cada
13 changed files with 946 additions and 9 deletions

323
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "nextcloud-explorer",
"version": "0.1.0",
"dependencies": {
"@codemirror/lang-markdown": "^6.3.4",
"@elastic/elasticsearch": "^9.1.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@ -17,6 +18,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^10.11.0",
"@tanstack/react-query": "^5.87.4",
"@uiw/react-codemirror": "^4.25.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@ -257,6 +259,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@ -330,6 +341,159 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.18.7",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.7.tgz",
"integrity": "sha512-8EzdeIoWPJDsMBwz3zdzwXnUpCzMiCyz5/A3FIPpriaclFCGDkAzK13sMcnsu5rowqiyeQN2Vs2TsOcoDPZirQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
"integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.10",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.10.tgz",
"integrity": "sha512-h/SceTVsN5r+WE+TVP2g3KDvNoSzbSrtZXCKo4vkKdbfT5t4otuVgngGdFukOO/rwRD2++pCxoh6xD4TEVMkQA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.4.tgz",
"integrity": "sha512-fBm0BO03azXnTAsxhONDYHi/qWSI+uSEIpzKM7h/bkIc9fHnFp9y7KTMXKON0teNT97pFhc1a9DQTtWBYEZ7ug==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.11.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.8.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.2",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.2.tgz",
"integrity": "sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@elastic/elasticsearch": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-9.1.1.tgz",
@ -1556,6 +1720,79 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
"integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.3.tgz",
"integrity": "sha512-jexmlKq5NpGiB7t+0QkyhSXRgaiab5YisHIQW9C7EcU19KSUsDguZe9WY+rmRDg34nXoNH2LQ4SxpC+aJUchSQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz",
"integrity": "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -4665,6 +4902,59 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.1.tgz",
"integrity": "sha512-zxgA2QkvP3ZDKxTBc9UltNFTrSeFezGXcZtZj6qcsBxiMzowoEMP5mVwXcKjpzldpZVRuY+JCC+RsekEgid4vg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/autocomplete": ">=6.0.0",
"@codemirror/commands": ">=6.0.0",
"@codemirror/language": ">=6.0.0",
"@codemirror/lint": ">=6.0.0",
"@codemirror/search": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@uiw/react-codemirror": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.1.tgz",
"integrity": "sha512-eESBKHndoYkaEGlKCwRO4KrnTw1HkWBxVpEeqntoWTpoFEUYxdLWUYmkPBVk4/u8YzVy9g91nFfIRpqe5LjApg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@codemirror/commands": "^6.1.0",
"@codemirror/state": "^6.1.1",
"@codemirror/theme-one-dark": "^6.0.0",
"@uiw/codemirror-extensions-basic-setup": "4.25.1",
"codemirror": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.11.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/theme-one-dark": ">=6.0.0",
"@codemirror/view": ">=6.0.0",
"codemirror": ">=6.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
@ -5834,6 +6124,21 @@
"node": ">=6"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -5941,6 +6246,12 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -10333,6 +10644,12 @@
],
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
"license": "MIT"
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@ -10944,6 +11261,12 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",

View File

@ -10,6 +10,7 @@
"create:index": "tsx -r dotenv/config -r tsconfig-paths/register scripts/create-index.ts"
},
"dependencies": {
"@codemirror/lang-markdown": "^6.3.4",
"@elastic/elasticsearch": "^9.1.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@ -19,6 +20,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^10.11.0",
"@tanstack/react-query": "^5.87.4",
"@uiw/react-codemirror": "^4.25.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",

View File

@ -0,0 +1,77 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { normalizePath } from "@/lib/paths";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
// GET ?path=/abs/path -> { content, mimeType }
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const rawPath = searchParams.get("path");
if (!rawPath) {
return json({ error: "path is required" }, { status: 400 });
}
const path = normalizePath(rawPath);
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.content.get" },
async (span) => {
span.setAttribute("path", path);
const res = await nextcloud.readText(path);
return res;
},
);
return json(result);
} catch (error: unknown) {
Sentry.captureException(error);
const message = error instanceof Error ? error.message : String(error);
const status = /404/.test(message) ? 404 : 500;
return json({ error: "Failed to read file content", message }, { status });
}
}
// PUT { path, content, mimeType? } -> { ok, etag? }
export async function PUT(req: Request) {
try {
const body = (await req.json().catch(() => ({}))) as {
path?: string;
content?: string;
mimeType?: string;
};
if (!body?.path || typeof body.content !== "string") {
return json({ error: "path and content are required" }, { status: 400 });
}
const path = normalizePath(body.path);
const mime = body.mimeType || "text/markdown";
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.content.put" },
async (span) => {
span.setAttribute("path", path);
span.setAttribute("content.length", body.content!.length);
span.setAttribute("mimeType", mime);
const res = await nextcloud.writeText(path, body.content!, mime);
return res;
},
);
return json(result);
} catch (error: unknown) {
Sentry.captureException(error);
const message = error instanceof Error ? error.message : String(error);
const status = /409|conflict/i.test(message)
? 409
: /423|locked/i.test(message)
? 423
: 500;
return json({ error: "Failed to write file content", message }, { status });
}
}

View File

@ -0,0 +1,48 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { normalizePath } from "@/lib/paths";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function POST(req: Request) {
try {
const body = (await req.json().catch(() => ({}))) as {
from?: string;
to?: string;
};
if (!body?.from || !body?.to) {
return json({ error: "from and to are required" }, { status: 400 });
}
const from = normalizePath(body.from);
const to = normalizePath(body.to);
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.copy" },
async (span) => {
span.setAttribute("path_from", from);
span.setAttribute("path_to", to);
const res = await nextcloud.copyFile(from, to);
return res;
},
);
return json(result);
} catch (error: unknown) {
Sentry.captureException(error);
const message = error instanceof Error ? error.message : String(error);
const status = /409|conflict/i.test(message)
? 409
: /423|locked/i.test(message)
? 423
: 500;
return json({ error: "Failed to copy file", message }, { status });
}
}

View File

@ -0,0 +1,45 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { normalizePath } from "@/lib/paths";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function POST(req: Request) {
try {
const body = (await req.json().catch(() => ({}))) as {
path?: string;
};
if (!body?.path) {
return json({ error: "path is required" }, { status: 400 });
}
const path = normalizePath(body.path);
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.delete" },
async (span) => {
span.setAttribute("path", path);
const res = await nextcloud.deletePath(path);
return res;
},
);
return json(result);
} catch (error: unknown) {
Sentry.captureException(error);
const message = error instanceof Error ? error.message : String(error);
const status = /409|conflict/i.test(message)
? 409
: /423|locked/i.test(message)
? 423
: 500;
return json({ error: "Failed to delete path", message }, { status });
}
}

View File

@ -0,0 +1,49 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { normalizePath } from "@/lib/paths";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function POST(req: Request) {
try {
const body = (await req.json().catch(() => ({}))) as {
from?: string;
to?: string;
};
if (!body?.from || !body?.to) {
return json({ error: "from and to are required" }, { status: 400 });
}
const from = normalizePath(body.from);
const to = normalizePath(body.to);
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.rename" },
async (span) => {
span.setAttribute("path_from", from);
span.setAttribute("path_to", to);
const res = await nextcloud.moveFile(from, to);
return res;
},
);
return json(result);
} catch (error: unknown) {
Sentry.captureException(error);
const message = error instanceof Error ? error.message : String(error);
// Map some common WebDAV conflict/locked responses if available
const status = /409|conflict/i.test(message)
? 409
: /423|locked/i.test(message)
? 423
: 500;
return json({ error: "Failed to rename file", message }, { status });
}
}

View File

@ -9,6 +9,9 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useQuery } from "@tanstack/react-query";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MarkdownEditor } from "@/components/editor/markdown-editor";
import { toast } from "sonner";
type FilesListResponse = {
total: number;
@ -76,6 +79,9 @@ export default function Home() {
const [semantic, setSemantic] = React.useState(false);
const searching = q.trim().length > 0;
// Editor state
const [editPath, setEditPath] = React.useState<string | null>(null);
const filesQuery = useQuery({
queryKey: ["files", path, page, perPage],
queryFn: () => fetchFiles(path, page, perPage),
@ -125,6 +131,67 @@ export default function Home() {
handleDownload(item);
}
function handleEdit(item: FileRow) {
setEditPath(item.path);
}
async function postJSON(url: string, body: unknown) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.message || `Request failed (${res.status})`);
}
return res.json();
}
async function handleRename(item: FileRow) {
const newName = window.prompt("New name (or full destination path):", item.name);
if (!newName) return;
const to =
newName.startsWith("/")
? newName
: `${item.parentPath ?? ""}/${newName}`.replace(/\/+/g, "/");
try {
await postJSON("/api/files/rename", { from: item.path, to });
toast.success("Renamed");
if (!searching) filesQuery.refetch();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleCopy(item: FileRow) {
const newPath = window.prompt("Copy to path (absolute or name in same folder):", `${item.parentPath ?? ""}/${item.name}`);
if (!newPath) return;
const to =
newPath.startsWith("/")
? newPath
: `${item.parentPath ?? ""}/${newPath}`.replace(/\/+/g, "/");
try {
await postJSON("/api/files/copy", { from: item.path, to });
toast.success("Copied");
if (!searching) filesQuery.refetch();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(item: FileRow) {
const yes = window.confirm(`Delete "${item.name}"? This cannot be undone.`);
if (!yes) return;
try {
await postJSON("/api/files/delete", { path: item.path });
toast.success("Deleted");
if (!searching) filesQuery.refetch();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
function handleUploaded() {
if (!searching) {
filesQuery.refetch();
@ -181,9 +248,29 @@ export default function Home() {
: `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`}
</div>
) : null}
<FileTable items={files} onOpen={handleOpen} onDownload={handleDownload} />
<FileTable
items={files}
onOpen={handleOpen}
onDownload={handleDownload}
onEdit={handleEdit}
onRename={handleRename}
onCopy={handleCopy}
onDelete={handleDelete}
/>
</section>
</main>
{/* Editor dialog */}
<Dialog open={!!editPath} onOpenChange={(v) => !v && setEditPath(null)}>
<DialogContent className="max-w-4xl h-[80vh]">
<DialogHeader>
<DialogTitle>Markdown Editor</DialogTitle>
</DialogHeader>
<div className="h-[calc(80vh-60px)]">
{editPath ? <MarkdownEditor path={editPath} /> : null}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import * as React from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import CodeMirror from "@uiw/react-codemirror";
import { markdown } from "@codemirror/lang-markdown";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
type Props = {
path: string;
};
async function fetchContent(path: string) {
const url = new URL("/api/files/content", window.location.origin);
url.searchParams.set("path", path);
const res = await fetch(url.toString(), { cache: "no-store" });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.message || `Failed to load content (${res.status})`);
}
return (await res.json()) as { ok: boolean; content?: string; mimeType?: string };
}
async function saveContent(path: string, content: string, mimeType?: string) {
const res = await fetch("/api/files/content", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path, content, mimeType: mimeType || "text/markdown" }),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.message || `Failed to save content (${res.status})`);
}
return (await res.json()) as { ok: boolean; etag?: string };
}
export function MarkdownEditor({ path }: Props) {
const [value, setValue] = React.useState("");
const [dirty, setDirty] = React.useState(false);
const query = useQuery({
queryKey: ["file-content", path],
queryFn: () => fetchContent(path),
refetchOnWindowFocus: false,
});
React.useEffect(() => {
if (query.data?.content != null) {
setValue(query.data.content);
setDirty(false);
}
}, [query.data?.content]);
const mutation = useMutation({
mutationFn: (payload: { content: string; mimeType?: string }) =>
saveContent(path, payload.content, payload.mimeType),
onSuccess: () => {
toast.success("Saved");
setDirty(false);
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : String(err));
},
});
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="text-sm text-muted-foreground truncate">Editing: <code>{path}</code></div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
{query.isFetching ? "Refreshing..." : "Refresh"}
</Button>
<Button
size="sm"
onClick={() => mutation.mutate({ content: value, mimeType: query.data?.mimeType || "text/markdown" })}
disabled={mutation.isPending || !dirty}
>
{mutation.isPending ? "Saving..." : "Save"}
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<CodeMirror
value={value}
height="calc(100vh - 140px)"
extensions={[markdown()]}
onChange={(v) => {
setValue(v);
setDirty(true);
}}
theme="dark"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Pencil, Copy, Trash2, Download, ScanText, MoveRight } from "lucide-react";
export type FileRowActionHandlers = {
onEdit?: () => void;
onDownload?: () => void;
onRename?: () => void;
onCopy?: () => void;
onDelete?: () => void;
};
export function FileRowActions({
onEdit,
onDownload,
onRename,
onCopy,
onDelete,
}: FileRowActionHandlers) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" aria-label="Actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={onDownload}>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onRename}>
<MoveRight className="mr-2 h-4 w-4" />
Rename / Move
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopy}>
<Copy className="mr-2 h-4 w-4" />
Copy
</DropdownMenuItem>
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -4,6 +4,7 @@ import * as React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Download } from "lucide-react";
import { FileRowActions } from "@/components/files/file-row-actions";
export interface FileRow {
id: string;
@ -28,10 +29,18 @@ export function FileTable({
items,
onOpen,
onDownload,
onEdit,
onRename,
onCopy,
onDelete,
}: {
items: FileRow[];
onOpen?: (item: FileRow) => void;
onDownload?: (item: FileRow) => void;
onEdit?: (item: FileRow) => void;
onRename?: (item: FileRow) => void;
onCopy?: (item: FileRow) => void;
onDelete?: (item: FileRow) => void;
}) {
return (
<div className="rounded-md border">
@ -59,6 +68,7 @@ export function FileTable({
<TableCell className="hidden sm:table-cell">{it.mimeType}</TableCell>
<TableCell className="hidden sm:table-cell text-right">{formatBytes(it.sizeBytes)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
@ -67,6 +77,14 @@ export function FileTable({
>
<Download className="h-4 w-4" />
</Button>
<FileRowActions
onEdit={() => onEdit?.(it)}
onDownload={() => onDownload?.(it)}
onRename={() => onRename?.(it)}
onCopy={() => onCopy?.(it)}
onDelete={() => onDelete?.(it)}
/>
</div>
</TableCell>
</TableRow>
))}

View File

@ -67,6 +67,10 @@ const EnvSchema = z.object({
OPENAI_EMBEDDING_MODEL: optionalString,
EMBEDDING_DIM: numberFromEnv, // e.g., 1536
// Qdrant (optional)
QDRANT_URL: optionalUrl,
QDRANT_API_KEY: optionalString,
// Sentry (optional)
SENTRY_DSN: optionalUrl,
});

View File

@ -178,6 +178,88 @@ export class NextcloudClient {
},
);
}
async moveFile(from: string, to: string): Promise<{ ok: boolean; from: string; to: string }> {
return Sentry.startSpan(
{ op: "http.client", name: "WebDAV MOVE moveFile" },
async (span) => {
const src = this.resolve(from);
const dst = this.resolve(to);
span.setAttribute("path_from", src);
span.setAttribute("path_to", dst);
const client = getClient();
await client.moveFile(src, dst, { overwrite: true });
return { ok: true, from: src, to: dst };
},
);
}
async copyFile(from: string, to: string): Promise<{ ok: boolean; from: string; to: string }> {
return Sentry.startSpan(
{ op: "http.client", name: "WebDAV COPY copyFile" },
async (span) => {
const src = this.resolve(from);
const dst = this.resolve(to);
span.setAttribute("path_from", src);
span.setAttribute("path_to", dst);
const client = getClient();
await client.copyFile(src, dst, { overwrite: true });
return { ok: true, from: src, to: dst };
},
);
}
async deletePath(path: string): Promise<{ ok: boolean }> {
return Sentry.startSpan(
{ op: "http.client", name: "WebDAV DELETE deletePath" },
async (span) => {
const target = this.resolve(path);
span.setAttribute("path", target);
const client = getClient();
try {
// Determine if directory or file for the correct delete call
const s = (await client.stat(target)) as FileStat;
if (s.type === "directory") {
await client.deleteFile(target); // webdav client uses deleteFile for both in many versions
} else {
await client.deleteFile(target);
}
return { ok: true };
} catch (err) {
Sentry.captureException(err);
throw err;
}
},
);
}
async readText(path: string): Promise<{ ok: boolean; content?: string; mimeType?: string }> {
return Sentry.startSpan(
{ op: "http.client", name: "WebDAV GET readText" },
async (span) => {
const target = this.resolve(path);
span.setAttribute("path", target);
const client = getClient();
const text = (await client.getFileContents(target, { format: "text" })) as unknown as string;
const s = await this.stat(target);
return { ok: true, content: text, mimeType: s?.contentType || "text/plain" };
},
);
}
async writeText(path: string, content: string, mimeType?: string): Promise<{ ok: boolean; etag?: string }> {
return Sentry.startSpan(
{ op: "http.client", name: "WebDAV PUT writeText" },
async (span) => {
const target = this.resolve(path);
span.setAttribute("path", target);
if (mimeType) span.setAttribute("contentType", mimeType);
const client = getClient();
await client.putFileContents(target, content, { overwrite: true });
return { ok: true };
},
);
}
}
export const nextcloud = new NextcloudClient();

View File

@ -47,3 +47,41 @@ export interface WebDavEntry {
contentType?: string;
props?: Record<string, unknown>;
}
/**
* Additional payloads for CRUD and tagging flows
*/
export interface RenamePayload {
from: string; // PathId
to: string; // PathId
}
export interface CopyPayload {
from: string; // PathId
to: string; // PathId
}
export interface DeletePayload {
path: string; // PathId
}
export interface FileContent {
path: string; // PathId
content: string; // UTF-8 text
mimeType?: string; // default text/markdown
}
export interface TagUpdatePayload {
path: string; // PathId
tags: string[];
}
export interface TagHistoryEvent {
id: string; // uuid
path: string; // PathId at time of change
actor?: string; // principal (NEXTCLOUD_USERNAME)
action: "tags.update";
from?: string[]; // previous tags
to: string[]; // new tags
at: string; // ISO timestamp
}