diff --git a/bun.lock b/bun.lock
index 97946082..1b1ea09a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -46,6 +46,8 @@
"@types/three": "^0.182.0",
"@typescript/native-preview": "^7.0.0-dev.20260107.1",
"@uiw/react-textarea-code-editor": "^3.1.1",
+ "@visual-json/core": "^0.1.1",
+ "@visual-json/react": "^0.1.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"argon2": "^0.44.0",
@@ -1051,6 +1053,10 @@
"@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="],
+ "@visual-json/core": ["@visual-json/core@0.1.1", "", {}, "sha512-VLARpQnjJU1SnnYk1NiXScGfp2FyAwyZS2kA8hIJV5OzkaH2oS1dTHsaB8vOiyi14/L7jsbpA4g/EwzRWbxQmg=="],
+
+ "@visual-json/react": ["@visual-json/react@0.1.1", "", { "dependencies": { "@visual-json/core": "0.1.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-KCpFYmBhH5F+xctfvxxVX7mVZK/iwvDwh/ITNkGPzEKXTIJQ16fau+hrG7m7QoTAOxvsdhHtHoCUiEjNHXboUQ=="],
+
"@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
diff --git a/package.json b/package.json
index 7d3ddab7..1c89b08b 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,8 @@
"@types/three": "^0.182.0",
"@typescript/native-preview": "^7.0.0-dev.20260107.1",
"@uiw/react-textarea-code-editor": "^3.1.1",
+ "@visual-json/core": "^0.1.1",
+ "@visual-json/react": "^0.1.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"argon2": "^0.44.0",
@@ -100,6 +102,7 @@
"node-pty": "^1.1.0",
"nodemailer": "^7.0.12",
"officerdb": "workspace:*",
+ "officerdev": "workspace:*",
"pg": "^8.16.3",
"postgres": "^3.4.5",
"react": "^19",
@@ -125,7 +128,6 @@
"three": "^0.182.0",
"types": "workspace:*",
"vaul": "^1.1.2",
- "officerdev": "workspace:*",
"widgets": "workspace:*",
"ws": "^8.18.1",
"zod": "^4.2.1"
diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx
index eed6d0fb..47801dee 100644
--- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx
+++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx
@@ -1,4 +1,4 @@
-import { useRef } from 'react';
+import { useMemo, useRef } from 'react';
import { Loader2, FolderArchive } from 'lucide-react';
import { getLang, getRawUrl, getTranscodeUrl, needsTranscode, getArchiveBaseName } from './file-types';
import { useFileViewer } from './FileViewerContext';
@@ -11,6 +11,7 @@ import { MarkdownRenderer } from './renderers/MarkdownRenderer';
import { TextRenderer } from './renderers/TextRenderer';
import { ScrollToTopButton } from './renderers/ScrollToTopButton';
import { EditorRenderer } from './renderers/EditorRenderer';
+import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
export const FileViewerBody = () => {
const { filePath, fileName, root, fileType, content, loading, error, autoPlay, editing, setContent } = useFileViewer();
@@ -19,6 +20,17 @@ export const FileViewerBody = () => {
const videoSrc =
fileType === 'video' ? (needsTranscode(fileName) ? getTranscodeUrl(filePath, root) : getRawUrl(filePath, root)) : '';
+ const isJson = useMemo(() => {
+ if (!content) return false;
+ if (/\.jsonc?$/i.test(fileName)) return true;
+ try {
+ const parsed = JSON.parse(content);
+ return typeof parsed === 'object' && parsed !== null;
+ } catch {
+ return false;
+ }
+ }, [content, fileName]);
+
return (
{loading ? (
@@ -43,8 +55,15 @@ export const FileViewerBody = () => {
) : fileType === 'audio' ? (
+ ) : content !== null && editing && isJson ? (
+
) : content !== null && editing ? (
+ ) : content !== null && isJson ? (
+
+
+
+
) : content !== null ? (
fileType === 'code' ? (
diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx
index f25aa376..65892954 100644
--- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx
+++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx
@@ -16,6 +16,7 @@ export type FileViewerContextValue = {
extractAudioLoading: boolean;
extractLoading: boolean;
autoPlay: boolean;
+ editable: boolean;
editing: boolean;
setEditing: (editing: boolean) => void;
setContent: (content: string) => void;
diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerHeader.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerHeader.tsx
index e4ec43ba..cf199935 100644
--- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerHeader.tsx
+++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerHeader.tsx
@@ -22,6 +22,7 @@ export const FileViewerHeader = () => {
handleDownload,
handleSaveResult,
saveResultLoading,
+ editable,
editing,
setEditing,
} = useFileViewer();
@@ -48,7 +49,7 @@ export const FileViewerHeader = () => {
{fileType === 'code' ? getLang(fileName) : fileType}
- {(fileType === 'markdown' || fileType === 'code' || fileType === 'text') && content && (
+ {editable && content && (