diff --git a/.changeset/import-as-template.md b/.changeset/import-as-template.md
new file mode 100644
index 000000000..ca654c40f
--- /dev/null
+++ b/.changeset/import-as-template.md
@@ -0,0 +1,5 @@
+---
+'@inkeep/open-knowledge': patch
+---
+
+Add "Import as template" to the File Tree context menu. Right-click a markdown file to copy it into the folder's `.ok/templates/`, with the option to keep the original or convert it (delete the source). Backed by a new `POST /api/template/import` endpoint that carries over the source frontmatter.
diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx
index 2c6f62a57..a5a401d1a 100644
--- a/packages/app/src/components/FileTree.tsx
+++ b/packages/app/src/components/FileTree.tsx
@@ -29,6 +29,7 @@ import {
Copy,
CopyPlus,
EyeOff,
+ FileKey,
FilePlus,
FolderOpen,
FolderPlus,
@@ -195,6 +196,7 @@ import {
subscribeToFileTreeMenuActionDuplicate,
subscribeToFileTreeMenuActionRename,
} from '@/lib/file-tree-menu-action-events';
+import { importTemplate } from '@/lib/folder-config-api';
import { parseServerResponse, parseSuccessOrWarn } from '@/lib/parse-server-response';
import { createRefreshScheduler } from '@/lib/refresh-scheduler';
import { getRelaunchInFlightSnapshot, useRelaunchInFlight } from '@/lib/relaunch-store';
@@ -572,6 +574,7 @@ interface FileTreeMenuProps {
* Drives the folder menu's "New from template" hover submenu. */
onCreateFromTemplate: (parentDir: string, templateName: string) => void;
onDuplicate: (target: FileTreeTarget) => void;
+ onImportTemplate: (target: FileTreeTarget, deleteSource: boolean) => void;
onDelete: (targets: FileTreeTarget[]) => void;
onExpandSubtree: (treePath: string) => void;
onCollapseSubtree: (treePath: string) => void;
@@ -755,6 +758,7 @@ function FileTreeMenu({
onStartCreating,
onCreateFromTemplate,
onDuplicate,
+ onImportTemplate,
onDelete,
onExpandSubtree,
onCollapseSubtree,
@@ -1134,16 +1138,44 @@ function FileTreeMenu({
<>
{!isAsset ? (
- {
- close();
- onDuplicate(target);
- }}
- >
-
- Duplicate
-
+ <>
+
+
+
+ Import as template
+
+
+ {
+ close();
+ onImportTemplate(target, false);
+ }}
+ >
+ Keep original file
+
+ {
+ close();
+ onImportTemplate(target, true);
+ }}
+ >
+ Convert (delete original)
+
+
+
+ {
+ close();
+ onDuplicate(target);
+ }}
+ >
+
+ Duplicate
+
+ >
) : null}
(null);
const [deleteRequest, setDeleteRequest] = useState(null);
+ const [templateConvertRequest, setTemplateConvertRequest] = useState(null);
/**
* Set when `shell.trashItem` returns `{ ok: false }` for one or more
* targets during the Step 1 trash flow. Drives the rendering of
@@ -2152,6 +2185,62 @@ export function FileTree({
handleDuplicateTargetRef.current = handleDuplicateTarget;
});
+ async function handleImportTemplate(target: FileTreeTarget, deleteSource: boolean) {
+ if (target.kind !== 'file') return;
+ if (deleteSource) {
+ setTemplateConvertRequest(target);
+ return;
+ }
+ await executeImportTemplate(target, false);
+ }
+
+ async function executeImportTemplate(target: FileTreeTarget, deleteSource: boolean) {
+ if (busyPathRef.current !== null) return;
+ const clearBusyState = () => {
+ setBusyPath(null);
+ busyPathRef.current = null;
+ setTemplateConvertRequest(null);
+ };
+ busyPathRef.current = target.path;
+ setBusyPath(target.path);
+ setError(null);
+
+ const appPath = target.path;
+ const slash = appPath.lastIndexOf('/');
+ const targetFolder = slash === -1 ? '' : appPath.slice(0, slash);
+
+ const res = await importTemplate({
+ sourcePath: target.path,
+ targetFolder,
+ deleteSource,
+ });
+
+ if (!res.ok) {
+ toast.error(t`Failed to import template`, { description: res.error });
+ clearBusyState();
+ return;
+ }
+
+ if (deleteSource) {
+ await applyDeleteAftermath([target], [target.path], []);
+ // Optimistically remove from view if deleted, standard watcher sweeps later
+ setDocuments((current) => {
+ const next = current.filter(
+ (entry) => !(isDocumentEntry(entry) && entry.docName === target.path),
+ );
+ resetModelToDocuments(next);
+ markNextDocumentsAsApplied(next);
+ return next;
+ });
+ emitDocumentsChanged(['files', 'backlinks', 'graph']);
+ }
+
+ toast.success(t`Template imported`, {
+ description: res.path,
+ });
+ clearBusyState();
+ }
+
function recoverMarkdownRenameConflict(message: string): boolean {
const bareDestinationPath = parseAlreadyExistsRenamePath(message);
if (!bareDestinationPath || markdownTreeExtension(bareDestinationPath)) return false;
@@ -4446,6 +4535,7 @@ export function FileTree({
startCreating('file', parentDir, { template: templateName })
}
onDuplicate={handleDuplicateTarget}
+ onImportTemplate={handleImportTemplate}
onDelete={(targets) => setDeleteRequest({ targets })}
onExpandSubtree={expandSubtree}
onCollapseSubtree={collapseSubtree}
@@ -4514,6 +4604,29 @@ export function FileTree({
/>
)}
+