feat(project): IDE-style Product tab — codebase tile expands inline, files preview right
Each codebase becomes its own panel with a header and an expandable Gitea file tree inside. Clicking any file selects it and renders its content in the right-hand preview panel (monospaced; no syntax highlight yet). Single-codebase projects auto-expand the only codebase on load so the tree is visible immediately. Tree leaves are now interactive when an onSelectFile callback is provided; selected rows highlight subtly so the user can tell where the right pane's content came from. Made-with: Cursor
This commit is contained in:
@@ -2,18 +2,17 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { SectionScaffold, StatusPanel } from "@/components/project/section-scaffold";
|
||||
import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box } from "lucide-react";
|
||||
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
|
||||
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
|
||||
|
||||
/**
|
||||
* Product tab.
|
||||
* Product tab — IDE-style.
|
||||
*
|
||||
* Each tile is a CODEBASE in this project's repo. The list is
|
||||
* discovered server-side from Gitea by `/api/projects/[id]/codebases`:
|
||||
* - Turborepo (`apps/*`) → one tile per app
|
||||
* - Single-repo project → one tile pointing at the repo root
|
||||
* - No repo connected → empty-state CTA
|
||||
* Left column: codebases stack. Each codebase is a panel with its
|
||||
* own header (name) and an inline expandable Gitea file tree below.
|
||||
* Single-codebase projects auto-expand on load. Clicking a file in
|
||||
* any tree updates the right column with that file's content.
|
||||
*/
|
||||
|
||||
interface Codebase {
|
||||
@@ -35,14 +34,17 @@ export default function ProductTab() {
|
||||
|
||||
const [codebases, setCodebases] = useState<Codebase[] | null>(null);
|
||||
const [reason, setReason] = useState<CodebasesResponse["reason"]>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [selectedFile, setSelectedFile] = useState<{ codebaseId: string; path: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCodebases(null);
|
||||
setError(null);
|
||||
setListError(null);
|
||||
setReason(undefined);
|
||||
setSelectedFile(null);
|
||||
|
||||
fetch(`/api/projects/${projectId}/codebases`, { credentials: "include" })
|
||||
.then(async r => {
|
||||
@@ -54,10 +56,13 @@ export default function ProductTab() {
|
||||
if (cancelled) return;
|
||||
setCodebases(data.codebases);
|
||||
setReason(data.reason);
|
||||
if (data.codebases[0]) setSelectedId(data.codebases[0].id);
|
||||
// Auto-expand the first codebase so users see something
|
||||
if (data.codebases[0]) {
|
||||
setExpanded(new Set([data.codebases[0].id]));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) setError(err.message || "Failed to load");
|
||||
if (!cancelled) setListError(err.message || "Failed to load");
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -65,93 +70,225 @@ export default function ProductTab() {
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const selected = codebases?.find(c => c.id === selectedId) ?? codebases?.[0];
|
||||
const toggleCodebase = (id: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ── Loading
|
||||
if (codebases === null && !error) {
|
||||
return (
|
||||
<SectionScaffold
|
||||
subAreasHeading="Codebases"
|
||||
subAreas={[]}
|
||||
rightHeading="Preview"
|
||||
rightPanel={
|
||||
<StatusPanel>
|
||||
<Centered>
|
||||
<Loader2 size={14} className="animate-spin" /> Loading codebases…
|
||||
</Centered>
|
||||
</StatusPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error
|
||||
if (error) {
|
||||
return (
|
||||
<SectionScaffold
|
||||
subAreasHeading="Codebases"
|
||||
subAreas={[]}
|
||||
rightHeading="Preview"
|
||||
rightPanel={
|
||||
<StatusPanel>
|
||||
<Centered>
|
||||
<AlertCircle size={14} /> {error}
|
||||
</Centered>
|
||||
</StatusPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty (no repo or empty repo)
|
||||
if (!codebases || codebases.length === 0) {
|
||||
return (
|
||||
<SectionScaffold
|
||||
subAreasHeading="Codebases"
|
||||
subAreas={[]}
|
||||
rightHeading="Preview"
|
||||
rightPanel={
|
||||
<StatusPanel>
|
||||
<Centered>
|
||||
{reason === "no_repo"
|
||||
? "No Gitea repo is connected to this project yet."
|
||||
: "Repo is empty — push a first commit to see codebases here."}
|
||||
</Centered>
|
||||
</StatusPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Loaded
|
||||
return (
|
||||
<SectionScaffold
|
||||
subAreasHeading="Codebases"
|
||||
rightHeading={selected ? `Preview · ${selected.label}` : "Preview"}
|
||||
subAreas={codebases.map(cb => ({
|
||||
label: cb.label,
|
||||
hint: cb.hint ?? `apps/${cb.id}`,
|
||||
onClick: () => setSelectedId(cb.id),
|
||||
active: cb.id === selected?.id,
|
||||
}))}
|
||||
rightPanel={
|
||||
selected ? (
|
||||
<StatusPanel>
|
||||
<GiteaFileTree projectId={projectId} rootPath={selected.path} />
|
||||
</StatusPanel>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div style={pageWrap}>
|
||||
<div style={grid}>
|
||||
{/* ── Left: codebases column ── */}
|
||||
<section style={leftCol}>
|
||||
<h3 style={heading}>Codebases</h3>
|
||||
<div style={stack}>
|
||||
{codebases === null && !listError && (
|
||||
<Inline>
|
||||
<Loader2 size={13} className="animate-spin" /> Loading…
|
||||
</Inline>
|
||||
)}
|
||||
{listError && (
|
||||
<Inline>
|
||||
<AlertCircle size={13} /> {listError}
|
||||
</Inline>
|
||||
)}
|
||||
{codebases && codebases.length === 0 && (
|
||||
<Inline>
|
||||
{reason === "no_repo"
|
||||
? "No Gitea repo connected to this project yet."
|
||||
: "Repo is empty — push a first commit."}
|
||||
</Inline>
|
||||
)}
|
||||
{codebases?.map(cb => {
|
||||
const isOpen = expanded.has(cb.id);
|
||||
return (
|
||||
<article key={cb.id} style={codebaseTile}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCodebase(cb.id)}
|
||||
style={tileHeader}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span style={chevronCell}>
|
||||
{isOpen
|
||||
? <ChevronDown size={13} style={{ color: "#5f5e5a" }} />
|
||||
: <ChevronRight size={13} style={{ color: "#5f5e5a" }} />}
|
||||
</span>
|
||||
<Box size={13} style={{ color: "#5f5e5a", flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left" }}>
|
||||
<div style={tileLabel}>{cb.label}</div>
|
||||
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div style={tileBody}>
|
||||
<GiteaFileTree
|
||||
projectId={projectId}
|
||||
rootPath={cb.path}
|
||||
selectedPath={
|
||||
selectedFile?.codebaseId === cb.id ? selectedFile.path : undefined
|
||||
}
|
||||
onSelectFile={(p) =>
|
||||
setSelectedFile({ codebaseId: cb.id, path: p })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Right: file preview ── */}
|
||||
<aside style={rightCol}>
|
||||
<h3 style={heading}>
|
||||
{selectedFile ? `Preview · ${shortPath(selectedFile.path)}` : "Preview"}
|
||||
</h3>
|
||||
<div style={previewPanel}>
|
||||
<GiteaFileViewer
|
||||
projectId={projectId}
|
||||
path={selectedFile?.path ?? null}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Centered({ children }: { children: React.ReactNode }) {
|
||||
function shortPath(p: string) {
|
||||
const parts = p.split("/");
|
||||
if (parts.length <= 2) return p;
|
||||
return ".../" + parts.slice(-2).join("/");
|
||||
}
|
||||
|
||||
function Inline({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
color: "#5f5e5a", fontSize: "0.85rem", padding: "24px 12px", textAlign: "center",
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "12px 14px", fontSize: "0.82rem", color: "#5f5e5a",
|
||||
background: "#fff", border: "1px solid #efebe1", borderRadius: 8,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Styles
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const INK = {
|
||||
ink: "#1a1a1a",
|
||||
mid: "#5f5e5a",
|
||||
muted: "#a09a90",
|
||||
border: "#e8e4dc",
|
||||
borderSoft: "#efebe1",
|
||||
cardBg: "#fff",
|
||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||
} as const;
|
||||
|
||||
const pageWrap: React.CSSProperties = {
|
||||
padding: "28px 48px 48px",
|
||||
fontFamily: INK.fontSans,
|
||||
color: INK.ink,
|
||||
};
|
||||
|
||||
const grid: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
|
||||
gap: 28,
|
||||
maxWidth: 1400,
|
||||
margin: "0 auto",
|
||||
alignItems: "stretch",
|
||||
};
|
||||
|
||||
const leftCol: React.CSSProperties = {
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const rightCol: React.CSSProperties = {
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const heading: React.CSSProperties = {
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
color: INK.muted,
|
||||
margin: "0 0 14px",
|
||||
};
|
||||
|
||||
const stack: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
};
|
||||
|
||||
const codebaseTile: React.CSSProperties = {
|
||||
background: INK.cardBg,
|
||||
border: `1px solid ${INK.borderSoft}`,
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const tileHeader: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "12px 14px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderBottom: `1px solid transparent`,
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
};
|
||||
|
||||
const tileLabel: React.CSSProperties = {
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: INK.ink,
|
||||
marginBottom: 2,
|
||||
};
|
||||
|
||||
const tileHint: React.CSSProperties = {
|
||||
fontSize: "0.74rem",
|
||||
color: INK.mid,
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
const tileBody: React.CSSProperties = {
|
||||
padding: "8px 10px 12px",
|
||||
borderTop: `1px solid ${INK.borderSoft}`,
|
||||
};
|
||||
|
||||
const chevronCell: React.CSSProperties = {
|
||||
width: 14,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const previewPanel: React.CSSProperties = {
|
||||
background: INK.cardBg,
|
||||
border: `1px solid ${INK.border}`,
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
@@ -28,9 +28,19 @@ interface GiteaFileTreeProps {
|
||||
projectId: string;
|
||||
/** Repo path to root the tree at, e.g. "apps/web" */
|
||||
rootPath: string;
|
||||
/** Fires when the user clicks a file row. When omitted, files
|
||||
* are not interactive. */
|
||||
onSelectFile?: (path: string) => void;
|
||||
/** Path of the currently-selected file, used to highlight the row. */
|
||||
selectedPath?: string;
|
||||
}
|
||||
|
||||
export function GiteaFileTree({ projectId, rootPath }: GiteaFileTreeProps) {
|
||||
export function GiteaFileTree({
|
||||
projectId,
|
||||
rootPath,
|
||||
onSelectFile,
|
||||
selectedPath,
|
||||
}: GiteaFileTreeProps) {
|
||||
const [rootItems, setRootItems] = useState<TreeItem[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -151,6 +161,8 @@ export function GiteaFileTree({ projectId, rootPath }: GiteaFileTreeProps) {
|
||||
loadingPaths={loadingPaths}
|
||||
childrenByPath={childrenByPath}
|
||||
onToggle={toggleDir}
|
||||
onSelectFile={onSelectFile}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -164,13 +176,26 @@ interface NodeProps {
|
||||
loadingPaths: Set<string>;
|
||||
childrenByPath: Record<string, TreeItem[]>;
|
||||
onToggle: (path: string) => void;
|
||||
onSelectFile?: (path: string) => void;
|
||||
selectedPath?: string;
|
||||
}
|
||||
|
||||
function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }: NodeProps) {
|
||||
function Node({
|
||||
item,
|
||||
depth,
|
||||
expanded,
|
||||
loadingPaths,
|
||||
childrenByPath,
|
||||
onToggle,
|
||||
onSelectFile,
|
||||
selectedPath,
|
||||
}: NodeProps) {
|
||||
const isDir = item.type === "dir";
|
||||
const isOpen = expanded.has(item.path);
|
||||
const isLoading = loadingPaths.has(item.path);
|
||||
const children = childrenByPath[item.path];
|
||||
const isSelected = !isDir && selectedPath === item.path;
|
||||
const fileClickable = !isDir && typeof onSelectFile === "function";
|
||||
|
||||
const Icon = isDir
|
||||
? isOpen
|
||||
@@ -180,13 +205,27 @@ function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }:
|
||||
|
||||
const indent = depth * 14;
|
||||
|
||||
const interactive = isDir || fileClickable;
|
||||
const handleClick = () => {
|
||||
if (isDir) onToggle(item.path);
|
||||
else if (fileClickable) onSelectFile!(item.path);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ ...rowStyle, paddingLeft: 6 + indent, cursor: isDir ? "pointer" : "default" }}
|
||||
onClick={isDir ? () => onToggle(item.path) : undefined}
|
||||
role={isDir ? "button" : undefined}
|
||||
style={{
|
||||
...rowStyle,
|
||||
paddingLeft: 6 + indent,
|
||||
cursor: interactive ? "pointer" : "default",
|
||||
background: isSelected ? "rgba(26,26,26,0.06)" : "transparent",
|
||||
color: isSelected ? INK.ink : undefined,
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
}}
|
||||
onClick={interactive ? handleClick : undefined}
|
||||
role={interactive ? "button" : undefined}
|
||||
aria-expanded={isDir ? isOpen : undefined}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
>
|
||||
<span style={chevronCell}>
|
||||
{Icon && <Icon size={12} style={{ color: INK.mid }} />}
|
||||
@@ -194,7 +233,7 @@ function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }:
|
||||
{isDir ? (
|
||||
<Folder size={13} style={{ color: INK.stone, flexShrink: 0 }} />
|
||||
) : (
|
||||
<FileText size={13} style={{ color: INK.muted, flexShrink: 0 }} />
|
||||
<FileText size={13} style={{ color: isSelected ? INK.ink : INK.muted, flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={nameStyle}>{item.name}</span>
|
||||
{isLoading && (
|
||||
@@ -210,6 +249,8 @@ function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }:
|
||||
loadingPaths={loadingPaths}
|
||||
childrenByPath={childrenByPath}
|
||||
onToggle={onToggle}
|
||||
onSelectFile={onSelectFile}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
144
components/project/gitea-file-viewer.tsx
Normal file
144
components/project/gitea-file-viewer.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Read-only file viewer that pulls a file's content from Gitea via
|
||||
* `GET /api/projects/[projectId]/file?path=…`. No syntax highlight
|
||||
* yet — just monospaced text. Phase 2 can swap in Shiki/Prism if
|
||||
* the founders ever read enough code here to need it.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, AlertCircle, FileText } from "lucide-react";
|
||||
|
||||
interface GiteaFileViewerProps {
|
||||
projectId: string;
|
||||
/** Repo path of the file to view, e.g. "apps/web/package.json".
|
||||
* When null, an empty state prompts the user to pick a file. */
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
type: "file" | "dir";
|
||||
content?: string;
|
||||
encoding?: string;
|
||||
name?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) {
|
||||
setContent(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setContent(null);
|
||||
|
||||
fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async r => {
|
||||
const data = (await r.json()) as ApiResponse;
|
||||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||||
if (data.type !== "file") throw new Error("Not a file");
|
||||
return data.content ?? "";
|
||||
})
|
||||
.then(c => {
|
||||
if (!cancelled) setContent(c);
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) setError(err.message || "Failed to load file");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, path]);
|
||||
|
||||
if (!path) {
|
||||
return (
|
||||
<Centered>
|
||||
<FileText size={18} style={{ color: INK.muted }} />
|
||||
<span>Pick a file from the codebase to preview it here.</span>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Centered>
|
||||
<Loader2 size={14} className="animate-spin" style={{ color: INK.muted }} />
|
||||
<span>Loading {basename(path)}…</span>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Centered>
|
||||
<AlertCircle size={14} style={{ color: INK.muted }} />
|
||||
<span>{error}</span>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrap}>
|
||||
<pre style={pre}>
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function basename(p: string) {
|
||||
return p.split("/").pop() || p;
|
||||
}
|
||||
|
||||
function Centered({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
||||
gap: 10, color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INK = {
|
||||
ink: "#1a1a1a",
|
||||
mid: "#5f5e5a",
|
||||
muted: "#a09a90",
|
||||
border: "#e8e4dc",
|
||||
} as const;
|
||||
|
||||
const wrap: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
margin: "-4px -10px",
|
||||
};
|
||||
|
||||
const pre: React.CSSProperties = {
|
||||
margin: 0,
|
||||
padding: "8px 10px",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
fontSize: "0.78rem",
|
||||
lineHeight: 1.55,
|
||||
color: INK.ink,
|
||||
whiteSpace: "pre",
|
||||
tabSize: 2,
|
||||
};
|
||||
Reference in New Issue
Block a user