Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -6607,6 +6607,19 @@ svg {
width: 100%;
}

/* Markdown preview panel inside the editor modal. Chat-style rendering via
.markdown-content, scrollable, prose width capped for readability. */
.diff-viewer-preview {
height: 100%;
overflow: auto;
padding: 12px 20px;
}

.diff-viewer-preview .markdown-content {
max-width: 80ch;
margin: 0 auto;
}

/* Diff viewer line hover highlighting (via decoration API) */
.diff-viewer-editor .monaco-editor .diff-viewer-line-hover {
background-color: rgba(37, 99, 235, 0.08) !important;
Expand Down
151 changes: 143 additions & 8 deletions ui/src/vue/components/EditableFileModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
<template v-else-if="saveStatus === 'saved'">Saved</template>
<template v-else-if="saveStatus === 'error'">Error saving</template>
</span>
<div v-if="commentable" class="diff-viewer-mode-toggle">
<div v-if="commentable || isMarkdown" class="diff-viewer-mode-toggle">
<button
v-if="commentable"
v-tooltip.top="'Comment mode'"
:class="`diff-viewer-mode-btn ${mode === 'comment' ? 'active' : ''}`"
aria-label="Comment mode"
Expand All @@ -47,8 +48,21 @@
>
✏️
</button>
<button
v-if="isMarkdown"
v-tooltip.top="'Preview (Ctrl+Shift+K)'"
:class="`diff-viewer-mode-btn ${mode === 'preview' ? 'active' : ''}`"
aria-label="Preview mode"
@click="enterPreview"
>
👁️
</button>
</div>
<VimToggle v-if="isDesktop" :enabled="vimEnabled" @change="setVimEnabled" />
<VimToggle
v-if="isDesktop && mode !== 'comment'"
:enabled="vimEnabled"
@change="setVimEnabled"
/>
<button
v-tooltip.top="'Close (Esc)'"
class="diff-viewer-close"
Expand All @@ -73,9 +87,20 @@
class="diff-viewer-editor"
:style="{
display:
monacoLoaded && content !== null && loadStatus === 'loaded' ? 'block' : 'none',
monacoLoaded && content !== null && loadStatus === 'loaded' && mode !== 'preview'
? 'block'
: 'none',
}"
/>
<div
v-if="
mode === 'preview' && monacoLoaded && content !== null && loadStatus === 'loaded'
"
ref="previewRef"
class="diff-viewer-preview"
>
<MarkdownContent :text="previewText" />
</div>
<div v-if="isDesktop && vimActive" ref="vimStatusRef" class="monaco-vim-status" />
</template>
<!-- Floating "add comment" prompt shown next to a selection in comment mode -->
Expand Down Expand Up @@ -116,6 +141,7 @@ import { useVimEnabled, useMonacoVim } from "../composables/monacoVim";
import { lineCommentLabel, useMonacoComments } from "../composables/monacoComments";
import VimToggle from "./VimToggle.vue";
import CommentDialog from "./CommentDialog.vue";
import MarkdownContent from "./MarkdownContent.vue";

type SaveStatus = "idle" | "saving" | "saved" | "error";
type LoadStatus = "loading" | "loaded" | "error";
Expand Down Expand Up @@ -146,9 +172,31 @@ const content = ref<string | null>(null);
const loadStatus = ref<LoadStatus>("loading");
const monacoLoaded = ref(false);
const saveStatus = ref<SaveStatus>("idle");
// Interaction mode. Commentable modals open in edit mode (matching the plain
// editor behavior); the toggle switches to read-only click-to-comment.
const mode = ref<"comment" | "edit">("edit");
// Interaction mode: comment (read-only click-to-comment) / edit / preview
// (rendered markdown). Commentable modals open in edit mode (matching the
// plain editor behavior); the toggle switches to read-only click-to-comment.
// Preview is a view-only panel over the live editor content; entering
// preview leaves comment mode, and vim only applies in edit mode.
const mode = ref<"comment" | "edit" | "preview">("edit");

// Whether the file is markdown: gated on real markdown extensions (or an
// explicit language prop), NOT Monaco's "markdown" fallback for unknown
// extensions — an extensionless Makefile shouldn't get a Preview button.
const isMarkdown = computed(() => {
if (props.language) return props.language === "markdown";
return /\.(md|markdown|mdx)$/i.test(props.path);
});

// Enter preview: snapshot the current editor buffer (preview renders once per
// toggle, no per-keystroke re-rendering), dismiss any open comment dialog.
function enterPreview() {
showCommentDialog.value = null;
previewText.value = editor.value?.getValue() ?? content.value ?? "";
mode.value = "preview";
}

// Rendered-markdown snapshot for the preview panel, refreshed on each toggle.
const previewText = ref("");

// Monaco editor instances must NOT be deeply reactive: a plain ref() proxies
// the editor's huge internal object graph, so vim mode (which drives the editor
Expand All @@ -159,6 +207,9 @@ let monacoMod: typeof Monaco | null = null;
const containerRef = ref<HTMLDivElement | null>(null);
const contentRef = ref<HTMLDivElement | null>(null);
const vimStatusRef = ref<HTMLDivElement | null>(null);
// Scrollable preview panel (markdown preview mode); target for vim-style
// navigation keys.
const previewRef = ref<HTMLDivElement | null>(null);
let saveTimeout: number | null = null;
let statusTimeout: number | null = null;
const [vimEnabledRef, setVimEnabledFn] = useVimEnabled();
Expand Down Expand Up @@ -194,10 +245,11 @@ let commentsCleanup: (() => void) | null = null;
// Apply mode changes to the live editor: comment mode is read-only and must
// not drag-and-drop selections; leaving comment mode clears the prompt and,
// with pending text, the dialog stays (matching DiffViewer's behavior of
// clearing only the selection prompt).
// clearing only the selection prompt). Preview is a view-only overlay, so the
// editor is read-only there too; returning to edit restores writability.
watch(mode, (m) => {
clearPrompt();
editor.value?.updateOptions({ readOnly: m === "comment" });
editor.value?.updateOptions({ readOnly: m === "comment" || m === "preview" });
});

function onResize() {
Expand Down Expand Up @@ -412,8 +464,91 @@ watch(
{ immediate: true },
);

// --- Preview vim-style navigation ---
// When vim is enabled and the preview panel is shown, normal-mode keys scroll
// the rendered document. `i`/`a`/`o` return to the editor (vim's "insert
// mode" ≈ edit mode). Only active while focus isn't in a form control.
const PREVIEW_VIM_KEYS: Record<string, () => void> = {
j: () => previewRef.value?.scrollBy({ top: 40, behavior: "auto" }),
k: () => previewRef.value?.scrollBy({ top: -40, behavior: "auto" }),
"Ctrl+d": () =>
previewRef.value?.scrollBy({ top: previewRef.value.clientHeight / 2, behavior: "auto" }),
"Ctrl+u": () =>
previewRef.value?.scrollBy({ top: -previewRef.value.clientHeight / 2, behavior: "auto" }),
"Ctrl+f": () =>
previewRef.value?.scrollBy({ top: previewRef.value.clientHeight, behavior: "auto" }),
"Ctrl+b": () =>
previewRef.value?.scrollBy({ top: -previewRef.value.clientHeight, behavior: "auto" }),
};

let previewVimPendingG = false;
function handlePreviewVimKey(e: KeyboardEvent): boolean {
if (!previewRef.value || !vimEnabled.value || mode.value !== "preview") return false;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) {
return false;
}
const key = e.key.toLowerCase();
const plain = !e.ctrlKey && !e.altKey && !e.metaKey;
if (plain && key === "g" && !previewVimPendingG) {
previewVimPendingG = true;
setTimeout(() => (previewVimPendingG = false), 500);
return true;
}
if (plain && key === "g" && previewVimPendingG) {
previewVimPendingG = false;
previewRef.value?.scrollTo({ top: 0, behavior: "auto" });
return true;
}
previewVimPendingG = false;
// Shift+G jumps to the bottom; check the raw key (lowercase would collide
// with the gg pending state).
if (plain && e.key === "G") {
e.preventDefault();
previewRef.value?.scrollTo({ top: previewRef.value.scrollHeight, behavior: "auto" });
return true;
}
if (plain && (key === "i" || key === "a" || key === "o")) {
e.preventDefault();
mode.value = "edit";
editor.value?.focus();
return true;
}
if (plain && (key === "j" || key === "k")) {
e.preventDefault();
PREVIEW_VIM_KEYS[key]();
return true;
}
if (
e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
(key === "d" || key === "u" || key === "f" || key === "b")
) {
e.preventDefault();
PREVIEW_VIM_KEYS[`Ctrl+${key}`]();
return true;
}
return false;
}

// --- Escape handling (capture phase; vim-aware guard) ---
function handleKeyDown(e: KeyboardEvent) {
// Vim-style navigation in the markdown preview (only when vim is enabled).
if (mode.value === "preview" && vimEnabled.value && handlePreviewVimKey(e)) {
return;
}
if (e.key === "k" || e.key === "K") {
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isMarkdown.value) {
e.preventDefault();
if (mode.value === "preview") {
mode.value = "edit";
} else {
enterPreview();
}
return;
}
}
if (e.key !== "Escape") return;
// If vim mode is in a non-normal mode (insert/visual/...), let monaco-vim
// handle Escape (to drop back to normal) instead of closing the modal.
Expand Down
2 changes: 1 addition & 1 deletion ui/src/vue/composables/monacoComments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface CommentPromptInfo {
export interface MonacoCommentsDeps {
monaco: () => typeof Monaco | null;
/** Current interaction mode; all handlers are inert unless "comment". */
mode: () => "comment" | "edit";
mode: () => "comment" | "edit" | "preview";
isMobile: () => boolean;
/** Element the floating prompt is positioned relative to. */
promptHost: () => HTMLElement | null;
Expand Down