fix bug with CodeEditor/monaco model, more preview refactoring (#2353)

the primary purpose of this PR is to fix a showstopper bug in the
CodeEditor component by setting "path" to a stable UUID. the bug was
that it started as empty string, so it created a shared model between
all of the codeeditor components. now each will get their own monaco
model.

also took this opportunity to do more more preview view refactoring,
splitting up code, and more tailwind migrations.
This commit is contained in:
Mike Sawka 2025-09-15 16:01:29 -07:00 committed by GitHub
parent 50cc08a769
commit 77bbf74ef9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 468 additions and 597 deletions

View file

@ -162,7 +162,7 @@ export const resolveRemoteFile = async (filepath: string, resolveOpts: MarkdownR
const baseDirUri = formatRemoteUri(resolveOpts.baseDir, resolveOpts.connName);
const fileInfo = await RpcApi.FileJoinCommand(TabRpcClient, [baseDirUri, filepath]);
const remoteUri = formatRemoteUri(fileInfo.path, resolveOpts.connName);
console.log("markdown resolve", resolveOpts, filepath, "=>", baseDirUri, remoteUri);
// console.log("markdown resolve", resolveOpts, filepath, "=>", baseDirUri, remoteUri);
const usp = new URLSearchParams();
usp.set("path", remoteUri);
return getWebServerEndpoint() + "/wave/stream-file?" + usp.toString();

View file

@ -10,7 +10,7 @@ import {
transformBlocks,
} from "@/app/element/markdown-util";
import remarkMermaidToTag from "@/app/element/remark-mermaid-to-tag";
import { boundNumber, useAtomValueSafe } from "@/util/util";
import { boundNumber, useAtomValueSafe, cn } from "@/util/util";
import clsx from "clsx";
import { Atom } from "jotai";
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
@ -297,6 +297,7 @@ type MarkdownProps = {
showTocAtom?: Atom<boolean>;
style?: React.CSSProperties;
className?: string;
contentClassName?: string;
onClickExecute?: (cmd: string) => void;
resolveOpts?: MarkdownResolveOpts;
scrollable?: boolean;
@ -311,6 +312,7 @@ const Markdown = ({
showTocAtom,
style,
className,
contentClassName,
resolveOpts,
fontSizeOverride,
fixedFontSizeOverride,
@ -383,19 +385,30 @@ const Markdown = ({
};
const toc = useMemo(() => {
if (showToc && tocRef.current.length > 0) {
return tocRef.current.map((item) => {
if (showToc) {
if (tocRef.current.length > 0) {
return tocRef.current.map((item) => {
return (
<a
key={item.href}
className="toc-item"
style={{ "--indent-factor": item.depth } as React.CSSProperties}
onClick={() => setFocusedHeading(item.href)}
>
{item.value}
</a>
);
});
} else {
return (
<a
key={item.href}
className="toc-item"
style={{ "--indent-factor": item.depth } as React.CSSProperties}
onClick={() => setFocusedHeading(item.href)}
<div
className="toc-item toc-empty text-secondary"
style={{ "--indent-factor": 2 } as React.CSSProperties}
>
{item.value}
</a>
No sub-headings found
</div>
);
});
}
}
}, [showToc, tocRef]);
@ -444,7 +457,7 @@ const Markdown = ({
return (
<OverlayScrollbarsComponent
ref={contentsOsRef}
className="content"
className={cn("content", contentClassName)}
options={{ scrollbars: { autoHide: "leave" } }}
>
<ReactMarkdown
@ -460,7 +473,7 @@ const Markdown = ({
const NonScrollableMarkdown = () => {
return (
<div className="content non-scrollable">
<div className={cn("content non-scrollable", contentClassName)}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
@ -483,9 +496,9 @@ const Markdown = ({
<div className={clsx("markdown", className)} style={mergedStyle}>
{scrollable ? <ScrollableMarkdown /> : <NonScrollableMarkdown />}
{toc && (
<OverlayScrollbarsComponent className="toc" options={{ scrollbars: { autoHide: "leave" } }}>
<OverlayScrollbarsComponent className="toc mt-1" options={{ scrollbars: { autoHide: "leave" } }}>
<div className="toc-inner">
<h4>Table of Contents</h4>
<h4 className="font-bold">Table of Contents</h4>
{toc}
</div>
</OverlayScrollbarsComponent>

View file

@ -8,9 +8,7 @@ import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api";
import { configureMonacoYaml } from "monaco-yaml";
import React, { useMemo, useRef } from "react";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { boundNumber, makeConnRoute } from "@/util/util";
import { boundNumber } from "@/util/util";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
@ -19,7 +17,6 @@ import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"
import { SchemaEndpoints, getSchemaEndpointInfo } from "./schemaendpoints";
import ymlWorker from "./yamlworker?worker";
// there is a global monaco variable (TODO get the correct TS type)
declare var monaco: Monaco;
@ -109,15 +106,13 @@ function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions {
interface CodeEditorProps {
blockId: string;
text: string;
filename: string;
fileinfo: FileInfo;
readonly: boolean;
language?: string;
meta?: MetaType;
onChange?: (text: string) => void;
onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => () => void;
}
export function CodeEditor({ blockId, text, language, filename, fileinfo, meta, onChange, onMount }: CodeEditorProps) {
export function CodeEditor({ blockId, text, language, readonly, onChange, onMount }: CodeEditorProps) {
const divRef = useRef<HTMLDivElement>(null);
const unmountRef = useRef<() => void>(null);
const minimapEnabled = useOverrideConfigAtom(blockId, "editor:minimapenabled") ?? false;
@ -125,7 +120,7 @@ export function CodeEditor({ blockId, text, language, filename, fileinfo, meta,
const wordWrap = useOverrideConfigAtom(blockId, "editor:wordwrap") ?? false;
const fontSize = boundNumber(useOverrideConfigAtom(blockId, "editor:fontsize"), 6, 64);
const theme = "wave-theme-dark";
const [absPath, setAbsPath] = React.useState("");
const editorPath = useRef(crypto.randomUUID()).current;
React.useEffect(() => {
return () => {
@ -136,24 +131,6 @@ export function CodeEditor({ blockId, text, language, filename, fileinfo, meta,
};
}, []);
React.useEffect(() => {
const inner = async () => {
try {
const fileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [filename], {
route: makeConnRoute(meta.connection ?? ""),
});
setAbsPath(fileInfo.path);
} catch (e) {
setAbsPath(filename);
}
};
inner();
}, [filename]);
React.useEffect(() => {
console.log("abspath is", absPath);
}, [absPath]);
function handleEditorChange(text: string, ev: MonacoTypes.editor.IModelContentChangedEvent) {
if (onChange) {
onChange(text);
@ -168,13 +145,13 @@ export function CodeEditor({ blockId, text, language, filename, fileinfo, meta,
const editorOpts = useMemo(() => {
const opts = defaultEditorOptions();
opts.readOnly = fileinfo.readonly;
opts.readOnly = readonly;
opts.minimap.enabled = minimapEnabled;
opts.stickyScroll.enabled = stickyScrollEnabled;
opts.wordWrap = wordWrap ? "on" : "off";
opts.fontSize = fontSize;
return opts;
}, [minimapEnabled, stickyScrollEnabled, wordWrap, fontSize, fileinfo.readonly]);
}, [minimapEnabled, stickyScrollEnabled, wordWrap, fontSize, readonly]);
return (
<div className="flex flex-col w-full h-full overflow-hidden items-center justify-center">
@ -185,7 +162,7 @@ export function CodeEditor({ blockId, text, language, filename, fileinfo, meta,
options={editorOpts}
onChange={handleEditorChange}
onMount={handleEditorOnMount}
path={absPath}
path={editorPath}
language={language}
/>
</div>

View file

@ -90,16 +90,6 @@
display: flex;
flex-direction: column;
padding: 0 5px 5px 5px;
.dir-table-body-search-display {
display: flex;
border-radius: 3px;
padding: 0.25rem 0.5rem;
background-color: var(--warning-color);
.search-display-close-button {
margin-left: auto;
}
}
.dir-table-body-scroll-box {
position: relative;
@ -185,52 +175,6 @@
}
}
}
.dir-table-search-line {
display: flex;
justify-content: flex-end;
gap: 0.7rem;
.dir-table-search-box {
width: 0;
height: 0;
opacity: 0;
padding: 0;
border: none;
pointer-events: none;
}
}
}
.dir-table-button {
background-color: transparent;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
padding: 0.2rem;
border-radius: 6px;
input {
width: 0;
height: 0;
opacity: 0;
padding: 0;
border: none;
pointer-events: none;
}
&:hover {
background-color: var(--highlight-bg-color);
}
&:focus {
background-color: var(--highlight-bg-color);
}
&:focus-within {
background-color: var(--highlight-bg-color);
}
}
.entry-manager-overlay {

View file

@ -0,0 +1,65 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Button } from "@/app/element/button";
import { Input } from "@/app/element/input";
import React, { memo, useState } from "react";
export enum EntryManagerType {
NewFile = "New File",
NewDirectory = "New Folder",
EditName = "Rename",
}
export type EntryManagerOverlayProps = {
forwardRef?: React.Ref<HTMLDivElement>;
entryManagerType: EntryManagerType;
startingValue?: string;
onSave: (newValue: string) => void;
onCancel?: () => void;
style?: React.CSSProperties;
getReferenceProps?: () => any;
};
export const EntryManagerOverlay = memo(
({
entryManagerType,
startingValue,
onSave,
onCancel,
forwardRef,
style,
getReferenceProps,
}: EntryManagerOverlayProps) => {
const [value, setValue] = useState(startingValue);
return (
<div className="entry-manager-overlay" ref={forwardRef} style={style} {...(getReferenceProps?.() ?? {})}>
<div className="entry-manager-type">{entryManagerType}</div>
<div className="entry-manager-input">
<Input
value={value}
onChange={setValue}
autoFocus={true}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
onSave(value);
}
}}
/>
</div>
<div className="entry-manager-buttons">
<Button className="vertical-padding-4" onClick={() => onSave(value)}>
Save
</Button>
<Button className="vertical-padding-4 red outlined" onClick={onCancel}>
Cancel
</Button>
</div>
</div>
);
}
);
EntryManagerOverlay.displayName = "EntryManagerOverlay";

View file

@ -0,0 +1,175 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { globalStore } from "@/app/store/global";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { fireAndForget, isBlank } from "@/util/util";
import { Column } from "@tanstack/react-table";
import dayjs from "dayjs";
import React from "react";
import { type PreviewModel } from "./preview-model";
export const recursiveError = "recursive flag must be set for directory operations";
export const overwriteError = "set overwrite flag to delete the existing file";
export const mergeError = "set overwrite flag to delete the existing contents or set merge flag to merge the contents";
export const displaySuffixes = {
B: "b",
kB: "k",
MB: "m",
GB: "g",
TB: "t",
KiB: "k",
MiB: "m",
GiB: "g",
TiB: "t",
};
export function getBestUnit(bytes: number, si = false, sigfig = 3): string {
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "-";
if (bytes === 0) return "0B";
const units = si ? ["kB", "MB", "GB", "TB"] : ["KiB", "MiB", "GiB", "TiB"];
const divisor = si ? 1000 : 1024;
const idx = Math.min(Math.floor(Math.log(bytes) / Math.log(divisor)), units.length);
const unit = idx === 0 ? "B" : units[idx - 1];
const value = bytes / Math.pow(divisor, idx);
return `${parseFloat(value.toPrecision(sigfig))}${displaySuffixes[unit] ?? unit}`;
}
export function getLastModifiedTime(unixMillis: number, column: Column<FileInfo, number>): string {
const fileDatetime = dayjs(new Date(unixMillis));
const nowDatetime = dayjs(new Date());
let datePortion: string;
if (nowDatetime.isSame(fileDatetime, "date")) {
datePortion = "Today";
} else if (nowDatetime.subtract(1, "day").isSame(fileDatetime, "date")) {
datePortion = "Yesterday";
} else {
datePortion = dayjs(fileDatetime).format("M/D/YY");
}
if (column.getSize() > 120) {
return `${datePortion}, ${dayjs(fileDatetime).format("h:mm A")}`;
}
return datePortion;
}
const iconRegex = /^[a-z0-9- ]+$/;
export function isIconValid(icon: string): boolean {
if (isBlank(icon)) {
return false;
}
return icon.match(iconRegex) != null;
}
export function getSortIcon(sortType: string | boolean): React.ReactNode {
switch (sortType) {
case "asc":
return <i className="fa-solid fa-chevron-up dir-table-head-direction"></i>;
case "desc":
return <i className="fa-solid fa-chevron-down dir-table-head-direction"></i>;
default:
return null;
}
}
export function cleanMimetype(input: string): string {
const truncated = input.split(";")[0];
return truncated.trim();
}
export function handleRename(
model: PreviewModel,
path: string,
newPath: string,
isDir: boolean,
recursive: boolean,
setErrorMsg: (msg: ErrorMsg) => void
) {
fireAndForget(async () => {
try {
let srcuri = await model.formatRemoteUri(path, globalStore.get);
if (isDir) {
srcuri += "/";
}
await RpcApi.FileMoveCommand(TabRpcClient, {
srcuri,
desturi: await model.formatRemoteUri(newPath, globalStore.get),
opts: {
recursive,
},
});
} catch (e) {
const errorText = `${e}`;
console.warn(`Rename failed: ${errorText}`);
let errorMsg: ErrorMsg;
if (errorText.includes(recursiveError) && !recursive) {
errorMsg = {
status: "Confirm Rename Directory",
text: "Renaming a directory requires the recursive flag. Proceed?",
level: "warning",
buttons: [
{
text: "Rename Recursively",
onClick: () => handleRename(model, path, newPath, isDir, true, setErrorMsg),
},
],
};
} else {
errorMsg = {
status: "Rename Failed",
text: `${e}`,
};
}
setErrorMsg(errorMsg);
}
model.refreshCallback();
});
}
export function handleFileDelete(
model: PreviewModel,
path: string,
recursive: boolean,
setErrorMsg: (msg: ErrorMsg) => void
) {
fireAndForget(async () => {
const formattedPath = await model.formatRemoteUri(path, globalStore.get);
try {
await RpcApi.FileDeleteCommand(TabRpcClient, {
path: formattedPath,
recursive,
});
} catch (e) {
const errorText = `${e}`;
console.warn(`Delete failed: ${errorText}`);
let errorMsg: ErrorMsg;
if (errorText.includes(recursiveError) && !recursive) {
errorMsg = {
status: "Confirm Delete Directory",
text: "Deleting a directory requires the recursive flag. Proceed?",
level: "warning",
buttons: [
{
text: "Delete Recursively",
onClick: () => handleFileDelete(model, path, true, setErrorMsg),
},
],
};
} else {
errorMsg = {
status: "Delete Failed",
text: `${e}`,
};
}
setErrorMsg(errorMsg);
}
model.refreshCallback();
});
}

View file

@ -1,8 +1,6 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Button } from "@/app/element/button";
import { Input } from "@/app/element/input";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { atoms, getApi, globalStore } from "@/app/store/global";
import { RpcApi } from "@/app/store/wshclientapi";
@ -10,11 +8,11 @@ import { TabRpcClient } from "@/app/store/wshrpcutil";
import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil";
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
import { addOpenMenuItems } from "@/util/previewutil";
import { fireAndForget, isBlank } from "@/util/util";
import { fireAndForget } from "@/util/util";
import { formatRemoteUri } from "@/util/waveutil";
import { offset, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
import {
Column,
Header,
Row,
RowData,
Table,
@ -25,21 +23,54 @@ import {
useReactTable,
} from "@tanstack/react-table";
import clsx from "clsx";
import dayjs from "dayjs";
import { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import React, { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import React, { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useDrag, useDrop } from "react-dnd";
import { quote as shellQuote } from "shell-quote";
import { debounce } from "throttle-debounce";
import "./directorypreview.scss";
import { EntryManagerOverlay, EntryManagerOverlayProps, EntryManagerType } from "./entry-manager";
import {
cleanMimetype,
getBestUnit,
getLastModifiedTime,
getSortIcon,
handleFileDelete,
handleRename,
isIconValid,
mergeError,
overwriteError,
} from "./preview-directory-utils";
import { type PreviewModel } from "./preview-model";
const PageJumpSize = 20;
const recursiveError = "recursive flag must be set for directory operations";
const overwriteError = "set overwrite flag to delete the existing file";
const mergeError = "set overwrite flag to delete the existing contents or set merge flag to merge the contents";
interface DirectoryTableHeaderCellProps {
header: Header<FileInfo, unknown>;
}
function DirectoryTableHeaderCell({ header }: DirectoryTableHeaderCellProps) {
return (
<div
className="dir-table-head-cell"
key={header.id}
style={{ width: `calc(var(--header-${header.id}-size) * 1px)` }}
>
<div className="dir-table-head-cell-content" onClick={() => header.column.toggleSorting()}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{getSortIcon(header.column.getIsSorted())}
</div>
<div className="dir-table-head-resize-box">
<div
className="dir-table-head-resize"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
</div>
</div>
);
}
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> {
@ -65,138 +96,6 @@ interface DirectoryTableProps {
const columnHelper = createColumnHelper<FileInfo>();
const displaySuffixes = {
B: "b",
kB: "k",
MB: "m",
GB: "g",
TB: "t",
KiB: "k",
MiB: "m",
GiB: "g",
TiB: "t",
};
function getBestUnit(bytes: number, si: boolean = false, sigfig: number = 3): string {
if (bytes === undefined || bytes < 0) {
return "-";
}
const units = si ? ["kB", "MB", "GB", "TB"] : ["KiB", "MiB", "GiB", "TiB"];
const divisor = si ? 1000 : 1024;
let currentUnit = "B";
let currentValue = bytes;
let idx = 0;
while (currentValue > divisor && idx < units.length - 1) {
currentUnit = units[idx];
currentValue /= divisor;
idx += 1;
}
return `${parseFloat(currentValue.toPrecision(sigfig))}${displaySuffixes[currentUnit]}`;
}
function getLastModifiedTime(unixMillis: number, column: Column<FileInfo, number>): string {
const fileDatetime = dayjs(new Date(unixMillis));
const nowDatetime = dayjs(new Date());
let datePortion: string;
if (nowDatetime.isSame(fileDatetime, "date")) {
datePortion = "Today";
} else if (nowDatetime.subtract(1, "day").isSame(fileDatetime, "date")) {
datePortion = "Yesterday";
} else {
datePortion = dayjs(fileDatetime).format("M/D/YY");
}
if (column.getSize() > 120) {
return `${datePortion}, ${dayjs(fileDatetime).format("h:mm A")}`;
}
return datePortion;
}
const iconRegex = /^[a-z0-9- ]+$/;
function isIconValid(icon: string): boolean {
if (isBlank(icon)) {
return false;
}
return icon.match(iconRegex) != null;
}
function getSortIcon(sortType: string | boolean): React.ReactNode {
switch (sortType) {
case "asc":
return <i className="fa-solid fa-chevron-up dir-table-head-direction"></i>;
case "desc":
return <i className="fa-solid fa-chevron-down dir-table-head-direction"></i>;
default:
return null;
}
}
function cleanMimetype(input: string): string {
const truncated = input.split(";")[0];
return truncated.trim();
}
enum EntryManagerType {
NewFile = "New File",
NewDirectory = "New Folder",
EditName = "Rename",
}
type EntryManagerOverlayProps = {
forwardRef?: React.Ref<HTMLDivElement>;
entryManagerType: EntryManagerType;
startingValue?: string;
onSave: (newValue: string) => void;
onCancel?: () => void;
style?: React.CSSProperties;
getReferenceProps?: () => any;
};
const EntryManagerOverlay = memo(
({
entryManagerType,
startingValue,
onSave,
onCancel,
forwardRef,
style,
getReferenceProps,
}: EntryManagerOverlayProps) => {
const [value, setValue] = useState(startingValue);
return (
<div className="entry-manager-overlay" ref={forwardRef} style={style} {...getReferenceProps()}>
<div className="entry-manager-type">{entryManagerType}</div>
<div className="entry-manager-input">
<Input
value={value}
onChange={setValue}
autoFocus={true}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
onSave(value);
}
}}
/>
</div>
<div className="entry-manager-buttons">
<Button className="vertical-padding-4" onClick={() => onSave(value)}>
Save
</Button>
<Button className="vertical-padding-4 red outlined" onClick={onCancel}>
Cancel
</Button>
</div>
</div>
);
}
);
function DirectoryTable({
model,
data,
@ -287,63 +186,26 @@ function DirectoryTable({
const setEntryManagerProps = useSetAtom(entryManagerOverlayPropsAtom);
const updateName = useCallback((path: string, isDir: boolean) => {
const fileName = path.split("/").at(-1);
setEntryManagerProps({
entryManagerType: EntryManagerType.EditName,
startingValue: fileName,
onSave: (newName: string) => {
let newPath: string;
if (newName !== fileName) {
const lastInstance = path.lastIndexOf(fileName);
newPath = path.substring(0, lastInstance) + newName;
console.log(`replacing ${fileName} with ${newName}: ${path}`);
const handleRename = (recursive: boolean) =>
fireAndForget(async () => {
try {
let srcuri = await model.formatRemoteUri(path, globalStore.get);
if (isDir) {
srcuri += "/";
}
await RpcApi.FileMoveCommand(TabRpcClient, {
srcuri,
desturi: await model.formatRemoteUri(newPath, globalStore.get),
opts: {
recursive,
},
});
} catch (e) {
const errorText = `${e}`;
console.warn(`Rename failed: ${errorText}`);
let errorMsg: ErrorMsg;
if (errorText.includes(recursiveError)) {
errorMsg = {
status: "Confirm Rename Directory",
text: "Renaming a directory requires the recursive flag. Proceed?",
level: "warning",
buttons: [
{
text: "Rename Recursively",
onClick: () => handleRename(true),
},
],
};
} else {
errorMsg = {
status: "Rename Failed",
text: `${e}`,
};
}
setErrorMsg(errorMsg);
}
model.refreshCallback();
});
handleRename(false);
}
setEntryManagerProps(undefined);
},
});
}, []);
const updateName = useCallback(
(path: string, isDir: boolean) => {
const fileName = path.split("/").at(-1);
setEntryManagerProps({
entryManagerType: EntryManagerType.EditName,
startingValue: fileName,
onSave: (newName: string) => {
let newPath: string;
if (newName !== fileName) {
const lastInstance = path.lastIndexOf(fileName);
newPath = path.substring(0, lastInstance) + newName;
console.log(`replacing ${fileName} with ${newName}: ${path}`);
handleRename(model, path, newPath, isDir, false, setErrorMsg);
}
setEntryManagerProps(undefined);
},
});
},
[model, setErrorMsg]
);
const table = useReactTable({
data,
@ -421,6 +283,9 @@ function DirectoryTable({
}),
[]
);
const TableComponent = table.getState().columnSizingInfo.isResizingColumn ? MemoizedTableBody : TableBody;
return (
<OverlayScrollbarsComponent
options={{ scrollbars: { autoHide: "leave" } }}
@ -434,61 +299,24 @@ function DirectoryTable({
{table.getHeaderGroups().map((headerGroup) => (
<div className="dir-table-head-row" key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<div
className="dir-table-head-cell"
key={header.id}
style={{ width: `calc(var(--header-${header.id}-size) * 1px)` }}
>
<div
className="dir-table-head-cell-content"
onClick={() => header.column.toggleSorting()}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{getSortIcon(header.column.getIsSorted())}
</div>
<div className="dir-table-head-resize-box">
<div
className="dir-table-head-resize"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
</div>
</div>
<DirectoryTableHeaderCell key={header.id} header={header} />
))}
</div>
))}
</div>
{table.getState().columnSizingInfo.isResizingColumn ? (
<MemoizedTableBody
bodyRef={bodyRef}
model={model}
data={data}
table={table}
search={search}
focusIndex={focusIndex}
setFocusIndex={setFocusIndex}
setSearch={setSearch}
setSelectedPath={setSelectedPath}
setRefreshVersion={setRefreshVersion}
osRef={osRef.current}
/>
) : (
<TableBody
bodyRef={bodyRef}
model={model}
data={data}
table={table}
search={search}
focusIndex={focusIndex}
setFocusIndex={setFocusIndex}
setSearch={setSearch}
setSelectedPath={setSelectedPath}
setRefreshVersion={setRefreshVersion}
osRef={osRef.current}
/>
)}
<TableComponent
bodyRef={bodyRef}
model={model}
data={data}
table={table}
search={search}
focusIndex={focusIndex}
setFocusIndex={setFocusIndex}
setSearch={setSearch}
setSelectedPath={setSelectedPath}
setRefreshVersion={setRefreshVersion}
osRef={osRef.current}
/>
</OverlayScrollbarsComponent>
);
}
@ -563,40 +391,6 @@ function TableBody({
return;
}
const fileName = finfo.path.split("/").pop();
const handleFileDelete = (recursive: boolean) =>
fireAndForget(async () => {
const path = await model.formatRemoteUri(finfo.path, globalStore.get);
try {
await RpcApi.FileDeleteCommand(TabRpcClient, {
path,
recursive,
});
} catch (e) {
const errorText = `${e}`;
console.warn(`Delete failed: ${errorText}`);
let errorMsg: ErrorMsg;
if (errorText.includes(recursiveError)) {
errorMsg = {
status: "Confirm Delete Directory",
text: "Deleting a directory requires the recursive flag. Proceed?",
level: "warning",
buttons: [
{
text: "Delete Recursively",
onClick: () => handleFileDelete(true),
},
],
};
} else {
errorMsg = {
status: "Delete Failed",
text: `${e}`,
};
}
setErrorMsg(errorMsg);
}
setRefreshVersion((current) => current + 1);
});
const menu: ContextMenuItem[] = [
{
label: "New File",
@ -643,7 +437,7 @@ function TableBody({
},
{
label: "Delete",
click: () => handleFileDelete(false),
click: () => handleFileDelete(model, finfo.path, false, setErrorMsg),
}
);
ContextMenuModel.showContextMenu(menu, e);
@ -654,11 +448,19 @@ function TableBody({
return (
<div className="dir-table-body" ref={bodyRef}>
{search !== "" && (
<div className="dir-table-body-search-display" ref={warningBoxRef}>
<div className="flex rounded-[3px] py-1 px-2 bg-warning" ref={warningBoxRef}>
<span>Searching for "{search}"</span>
<div className="search-display-close-button dir-table-button" onClick={() => setSearch("")}>
<div
className="ml-auto bg-transparent flex justify-center items-center flex-col p-0.5 rounded-md hover:bg-hoverbg focus:bg-hoverbg focus-within:bg-hoverbg cursor-pointer"
onClick={() => setSearch("")}
>
<i className="fa-solid fa-xmark" />
<input type="text" value={search} onChange={() => {}} />
<input
type="text"
value={search}
onChange={() => {}}
className="w-0 h-0 opacity-0 p-0 border-none pointer-events-none"
/>
</div>
</div>
)}

View file

@ -16,8 +16,6 @@ function CodeEditPreview({ model }: SpecializedViewProps) {
const fileContent = useAtomValue(model.fileContent);
const setNewFileContent = useSetAtom(model.newFileContent);
const fileInfo = useAtomValue(model.statFile);
const fileName = fileInfo?.name;
const blockMeta = useAtomValue(model.blockAtom)?.meta;
function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean {
if (checkKeyPressed(e, "Cmd:e")) {
@ -67,9 +65,7 @@ function CodeEditPreview({ model }: SpecializedViewProps) {
<CodeEditor
blockId={model.blockId}
text={fileContent}
filename={fileName}
fileinfo={fileInfo}
meta={blockMeta}
readonly={fileInfo.readonly}
onChange={(text) => setNewFileContent(text)}
onMount={onMount}
/>

View file

@ -0,0 +1,84 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Button } from "@/app/element/button";
import { CopyButton } from "@/app/element/copybutton";
import clsx from "clsx";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { memo, useCallback } from "react";
export const ErrorOverlay = memo(({ errorMsg, resetOverlay }: { errorMsg: ErrorMsg; resetOverlay: () => void }) => {
const showDismiss = errorMsg.showDismiss ?? true;
const buttonClassName = "outlined grey font-size-11 vertical-padding-3 horizontal-padding-7";
let iconClass = "fa-solid fa-circle-exclamation text-error text-base";
if (errorMsg.level == "warning") {
iconClass = "fa-solid fa-triangle-exclamation text-warning text-base";
}
const handleCopyToClipboard = useCallback(async () => {
await navigator.clipboard.writeText(errorMsg.text);
}, [errorMsg.text]);
return (
<div className="absolute top-[0] left-1.5 right-1.5 z-[var(--zindex-block-mask-inner)] overflow-hidden bg-[var(--conn-status-overlay-bg-color)] backdrop-blur-[50px] rounded-md shadow-lg">
<div className="flex flex-row justify-between p-2.5 pl-3 font-normal text-sm leading-normal font-sans text-secondary">
<div
className={clsx("flex flex-row items-center gap-3 grow min-w-0 shrink", {
"items-start": true,
})}
>
<i className={iconClass}></i>
<div className="flex flex-col items-start gap-1 grow w-full shrink min-w-0">
<div className="max-w-full text-xs font-semibold leading-4 tracking-[0.11px] text-white overflow-hidden">
{errorMsg.status}
</div>
<OverlayScrollbarsComponent
className="group text-xs font-normal leading-[15px] tracking-[0.11px] text-wrap max-h-20 rounded-lg py-1.5 pl-0 relative w-full"
options={{ scrollbars: { autoHide: "leave" } }}
>
<CopyButton
className="invisible group-hover:visible flex absolute top-0 right-1 rounded backdrop-blur-lg p-1 items-center justify-end gap-1"
onClick={handleCopyToClipboard}
title="Copy"
/>
<div>{errorMsg.text}</div>
</OverlayScrollbarsComponent>
{!!errorMsg.buttons && (
<div className="flex flex-row gap-2">
{errorMsg.buttons?.map((buttonDef) => (
<Button
className={buttonClassName}
onClick={() => {
buttonDef.onClick();
resetOverlay();
}}
key={crypto.randomUUID()}
>
{buttonDef.text}
</Button>
))}
</div>
)}
</div>
{showDismiss && (
<div className="flex items-start">
<Button
className={clsx(buttonClassName, "fa-xmark fa-solid")}
onClick={() => {
if (errorMsg.closeAction) {
errorMsg.closeAction();
}
resetOverlay();
}}
/>
</div>
)}
</div>
</div>
</div>
);
});

View file

@ -19,13 +19,14 @@ function MarkdownPreview({ model }: SpecializedViewProps) {
};
}, [connName, fileInfo.dir]);
return (
<div className="view-preview view-preview-markdown">
<div className="flex flex-row h-full overflow-auto items-start justify-start">
<Markdown
textAtom={model.fileContent}
showTocAtom={model.markdownShowToc}
resolveOpts={resolveOpts}
fontSizeOverride={fontSizeOverride}
fixedFontSizeOverride={fixedFontSizeOverride}
contentClassName="pt-[5px] pr-[15px] pb-[10px] pl-[15px]"
/>
</div>
);

View file

@ -13,14 +13,14 @@ function ImageZoomControls() {
const { zoomIn, zoomOut, resetTransform } = useControls();
return (
<div className="tools">
<Button onClick={() => zoomIn()} title="Zoom In">
<div className="absolute flex flex-row z-[2] top-0 right-0 p-[5px] gap-1">
<Button onClick={() => zoomIn()} title="Zoom In" className="py-1 px-[5px]">
<i className="fa-sharp fa-plus" />
</Button>
<Button onClick={() => zoomOut()} title="Zoom Out">
<Button onClick={() => zoomOut()} title="Zoom Out" className="py-1 px-[5px]">
<i className="fa-sharp fa-minus" />
</Button>
<Button onClick={() => resetTransform()} title="Reset Zoom">
<Button onClick={() => resetTransform()} title="Reset Zoom" className="py-1 px-[5px]">
<i className="fa-sharp fa-rotate-left" />
</Button>
</div>
@ -29,13 +29,13 @@ function ImageZoomControls() {
function StreamingImagePreview({ url }: { url: string }) {
return (
<div className="view-preview view-preview-image">
<div className="flex flex-row h-full overflow-hidden items-center justify-center relative">
<TransformWrapper initialScale={1} centerOnInit pinch={{ step: 10 }}>
{({ zoomIn, zoomOut, resetTransform, ...rest }) => (
<>
<ImageZoomControls />
<TransformComponent>
<img src={url} />
<TransformComponent wrapperClass="!h-full !w-full">
<img src={url} className="z-[1]" />
</TransformComponent>
</>
)}
@ -57,15 +57,15 @@ function StreamingPreview({ model }: SpecializedViewProps) {
const streamingUrl = `${getWebServerEndpoint()}/wave/stream-file?${usp.toString()}`;
if (fileInfo.mimetype === "application/pdf") {
return (
<div className="view-preview view-preview-pdf">
<div className="flex flex-row h-full overflow-hidden items-center justify-center p-[5px]">
<iframe src={streamingUrl} width="100%" height="100%" name="pdfview" />
</div>
);
}
if (fileInfo.mimetype.startsWith("video/")) {
return (
<div className="view-preview view-preview-video">
<video controls>
<div className="flex flex-row h-full overflow-hidden items-center justify-center">
<video controls className="w-full h-full p-[10px] object-contain">
<source src={streamingUrl} />
</video>
</div>
@ -73,8 +73,8 @@ function StreamingPreview({ model }: SpecializedViewProps) {
}
if (fileInfo.mimetype.startsWith("audio/")) {
return (
<div className="view-preview view-preview-audio">
<audio controls>
<div className="flex flex-row h-full overflow-hidden items-center justify-center">
<audio controls className="w-full h-full p-[10px] object-contain">
<source src={streamingUrl} />
</audio>
</div>

View file

@ -1,106 +0,0 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.view-preview {
display: flex;
flex-direction: row;
height: 100%;
overflow: hidden;
align-items: center;
justify-content: center;
&.view-preview-markdown {
align-items: start;
justify-content: start;
overflow: auto;
.markdown .content {
padding: 5px 15px 10px 15px;
}
}
&.view-preview-text {
align-items: start;
justify-content: start;
overflow: auto;
pre {
font: var(--fixed-font);
}
}
&.view-preview-video,
&.view-preview-audio {
video,
audio {
width: 100%;
height: 100%;
padding: 10px;
object-fit: contain;
}
}
&.view-preview-image {
position: relative;
.tools {
position: absolute;
display: flex;
flex-direction: row;
z-index: 2;
top: 0;
right: 0;
padding: 5px;
gap: 4px;
button {
padding: 4px 5px;
}
}
img {
z-index: 1;
}
.react-transform-wrapper {
height: 100%;
width: 100%;
}
}
&.view-preview-pdf {
padding: 5px;
}
}
.full-preview {
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
}
.view-nav {
display: flex;
flex-shrink: 0;
padding: 0.2rem 0 0.2rem 0;
.view-nav-item {
border-radius: 3px;
padding: 0.2rem 0;
&.clickable {
cursor: pointer;
&:hover {
background-color: var(--highlight-bg-color);
}
}
&.current-file {
background-color: transparent;
cursor: default;
font-weight: bold;
}
}
}
.full-preview-content {
flex-grow: 1;
overflow: hidden;
}

View file

@ -1,25 +1,21 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Button } from "@/app/element/button";
import { CopyButton } from "@/app/element/copybutton";
import { CenteredDiv } from "@/app/element/quickelems";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion";
import { globalStore } from "@/store/global";
import { isBlank, makeConnRoute } from "@/util/util";
import clsx from "clsx";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { memo, useCallback, useEffect } from "react";
import { memo, useEffect } from "react";
import { CSVView } from "./csvview";
import { DirectoryPreview } from "./preview-directory";
import { CodeEditPreview } from "./preview-edit";
import { ErrorOverlay } from "./preview-error-overlay";
import { MarkdownPreview } from "./preview-markdown";
import type { PreviewModel } from "./preview-model";
import { StreamingPreview } from "./preview-streaming";
import "./preview.scss";
export type SpecializedViewProps = {
model: PreviewModel;
@ -149,9 +145,9 @@ function PreviewView({
return (
<>
<div key="fullpreview" className="full-preview scrollbar-hide-until-hover">
<div key="fullpreview" className="flex flex-col w-full overflow-hidden scrollbar-hide-until-hover">
{errorMsg && <ErrorOverlay errorMsg={errorMsg} resetOverlay={() => setErrorMsg(null)} />}
<div ref={contentRef} className="full-preview-content">
<div ref={contentRef} className="flex-grow overflow-hidden">
<SpecializedView parentRef={contentRef} model={model} />
</div>
</div>
@ -168,80 +164,4 @@ function PreviewView({
);
}
const ErrorOverlay = memo(({ errorMsg, resetOverlay }: { errorMsg: ErrorMsg; resetOverlay: () => void }) => {
const showDismiss = errorMsg.showDismiss ?? true;
const buttonClassName = "outlined grey font-size-11 vertical-padding-3 horizontal-padding-7";
let iconClass = "fa-solid fa-circle-exclamation text-[var(--error-color)] text-base";
if (errorMsg.level == "warning") {
iconClass = "fa-solid fa-triangle-exclamation text-[var(--warning-color)] text-base";
}
const handleCopyToClipboard = useCallback(async () => {
await navigator.clipboard.writeText(errorMsg.text);
}, [errorMsg.text]);
return (
<div className="absolute top-[0] left-1.5 right-1.5 z-[var(--zindex-block-mask-inner)] overflow-hidden bg-[var(--conn-status-overlay-bg-color)] backdrop-blur-[50px] rounded-md shadow-lg">
<div className="flex flex-row justify-between p-2.5 pl-3 font-[var(--base-font)] text-[var(--secondary-text-color)]">
<div
className={clsx("flex flex-row items-center gap-3 grow min-w-0 shrink", {
"items-start": true,
})}
>
<i className={iconClass}></i>
<div className="flex flex-col items-start gap-1 grow w-full shrink min-w-0">
<div className="max-w-full text-xs font-semibold leading-4 tracking-[0.11px] text-white overflow-hidden">
{errorMsg.status}
</div>
<OverlayScrollbarsComponent
className="group text-xs font-normal leading-[15px] tracking-[0.11px] text-wrap max-h-20 rounded-lg py-1.5 pl-0 relative w-full"
options={{ scrollbars: { autoHide: "leave" } }}
>
<CopyButton
className="invisible group-hover:visible flex absolute top-0 right-1 rounded backdrop-blur-lg p-1 items-center justify-end gap-1"
onClick={handleCopyToClipboard}
title="Copy"
/>
<div>{errorMsg.text}</div>
</OverlayScrollbarsComponent>
{!!errorMsg.buttons && (
<div className="flex flex-row gap-2">
{errorMsg.buttons?.map((buttonDef) => (
<Button
className={buttonClassName}
onClick={() => {
buttonDef.onClick();
resetOverlay();
}}
key={crypto.randomUUID()}
>
{buttonDef.text}
</Button>
))}
</div>
)}
</div>
{showDismiss && (
<div className="flex items-start">
<Button
className={clsx(buttonClassName, "fa-xmark fa-solid")}
onClick={() => {
if (errorMsg.closeAction) {
errorMsg.closeAction();
}
resetOverlay();
}}
/>
</div>
)}
</div>
</div>
</div>
);
});
export { PreviewView };