feat(files): IDE-style editor with line-number gutter, active line, and Ln/Col status bar

This commit is contained in:
Aditya Tripathi
2026-08-26 18:12:54 +00:00
parent 90b31be258
commit b22cb987fd
3 changed files with 182 additions and 60 deletions
+107 -53
View File
@@ -2311,112 +2311,166 @@ input[type="search"]::-webkit-search-results-decoration {
}
}
/* --- File browser TipTap code editor --- */
.file-editor {
/* --- File browser TipTap code editor (IDE look) --- */
.file-ide {
--file-line-height: 20px;
--file-pad-y: 12px;
position: relative;
display: flex;
max-height: 60vh;
overflow: auto;
border-radius: 8px;
border-radius: 8px 8px 0 0;
background: var(--coollabs-recessed);
box-shadow: inset 0 0 0 1px var(--coollabs-line);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: var(--file-line-height);
tab-size: 2;
}
.file-editor .file-editor-prosemirror,
.file-editor .ProseMirror {
/* Line-number gutter: pinned left, scrolls vertically with the code. */
.file-ide-gutter {
position: sticky;
left: 0;
z-index: 2;
flex-shrink: 0;
min-width: 2.75rem;
padding: var(--file-pad-y) 8px var(--file-pad-y) 10px;
text-align: right;
color: var(--coollabs-subtle);
background: var(--coollabs-recessed);
box-shadow: inset -1px 0 0 var(--coollabs-fill);
user-select: none;
}
.file-ide-gutter > div {
height: var(--file-line-height);
opacity: 0.55;
}
.file-ide-gutter > div.is-active {
opacity: 1;
color: var(--coollabs);
font-weight: 600;
}
.dark .file-ide-gutter > div.is-active {
color: var(--warning, #f5c211);
}
.file-ide-code {
flex: 1 1 auto;
min-width: 0;
}
.file-ide-code .file-editor-prosemirror,
.file-ide-code .ProseMirror {
outline: none;
min-height: 12rem;
}
.file-editor pre {
.file-ide pre {
margin: 0;
padding: 0.875rem 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
padding: var(--file-pad-y) 16px;
white-space: pre;
tab-size: 2;
color: var(--coollabs-fg, #1f2328);
}
.dark .file-editor pre {
.dark .file-ide pre {
color: #e6edf3;
}
/* Status bar */
.file-ide-status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 4px 12px;
border-radius: 0 0 8px 8px;
background: var(--coollabs-elevated);
box-shadow: inset 0 0 0 1px var(--coollabs-line);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
color: var(--coollabs-subtle);
}
/* highlight.js tokens (light) */
.file-editor .hljs-comment,
.file-editor .hljs-quote {
.file-ide .hljs-comment,
.file-ide .hljs-quote {
color: #6a737d;
font-style: italic;
}
.file-editor .hljs-keyword,
.file-editor .hljs-selector-tag,
.file-editor .hljs-literal {
.file-ide .hljs-keyword,
.file-ide .hljs-selector-tag,
.file-ide .hljs-literal {
color: #cf222e;
}
.file-editor .hljs-string,
.file-editor .hljs-meta .hljs-string {
.file-ide .hljs-string,
.file-ide .hljs-meta .hljs-string {
color: #0a3069;
}
.file-editor .hljs-number,
.file-editor .hljs-attr,
.file-editor .hljs-property {
.file-ide .hljs-number,
.file-ide .hljs-attr,
.file-ide .hljs-property {
color: #0550ae;
}
.file-editor .hljs-title,
.file-editor .hljs-title.function_,
.file-editor .hljs-section {
.file-ide .hljs-title,
.file-ide .hljs-title.function_,
.file-ide .hljs-section {
color: #8250df;
}
.file-editor .hljs-built_in,
.file-editor .hljs-type,
.file-editor .hljs-attribute {
.file-ide .hljs-built_in,
.file-ide .hljs-type,
.file-ide .hljs-attribute {
color: #953800;
}
.file-editor .hljs-tag,
.file-editor .hljs-name,
.file-editor .hljs-selector-id,
.file-editor .hljs-selector-class {
.file-ide .hljs-tag,
.file-ide .hljs-name,
.file-ide .hljs-selector-id,
.file-ide .hljs-selector-class {
color: #116329;
}
.file-editor .hljs-meta {
.file-ide .hljs-meta {
color: #57606a;
}
/* highlight.js tokens (dark) */
.dark .file-editor .hljs-comment,
.dark .file-editor .hljs-quote {
.dark .file-ide .hljs-comment,
.dark .file-ide .hljs-quote {
color: #8b949e;
}
.dark .file-editor .hljs-keyword,
.dark .file-editor .hljs-selector-tag,
.dark .file-editor .hljs-literal {
.dark .file-ide .hljs-keyword,
.dark .file-ide .hljs-selector-tag,
.dark .file-ide .hljs-literal {
color: #ff7b72;
}
.dark .file-editor .hljs-string,
.dark .file-editor .hljs-meta .hljs-string {
.dark .file-ide .hljs-string,
.dark .file-ide .hljs-meta .hljs-string {
color: #a5d6ff;
}
.dark .file-editor .hljs-number,
.dark .file-editor .hljs-attr,
.dark .file-editor .hljs-property {
.dark .file-ide .hljs-number,
.dark .file-ide .hljs-attr,
.dark .file-ide .hljs-property {
color: #79c0ff;
}
.dark .file-editor .hljs-title,
.dark .file-editor .hljs-title.function_,
.dark .file-editor .hljs-section {
.dark .file-ide .hljs-title,
.dark .file-ide .hljs-title.function_,
.dark .file-ide .hljs-section {
color: #d2a8ff;
}
.dark .file-editor .hljs-built_in,
.dark .file-editor .hljs-type,
.dark .file-editor .hljs-attribute {
.dark .file-ide .hljs-built_in,
.dark .file-ide .hljs-type,
.dark .file-ide .hljs-attribute {
color: #ffa657;
}
.dark .file-editor .hljs-tag,
.dark .file-editor .hljs-name,
.dark .file-editor .hljs-selector-id,
.dark .file-editor .hljs-selector-class {
.dark .file-ide .hljs-tag,
.dark .file-ide .hljs-name,
.dark .file-ide .hljs-selector-id,
.dark .file-ide .hljs-selector-class {
color: #7ee787;
}
.dark .file-editor .hljs-meta {
.dark .file-ide .hljs-meta {
color: #8b949e;
}
+65 -6
View File
@@ -24,17 +24,20 @@ function docFor(text, language) {
}
/**
* Register the `fileEditor` Alpine component used by the file browser's edit
* modal. The editor is recreated fresh each time a file is loaded (content
* baked into the initial doc) rather than mutated via setContent — that avoids
* ProseMirror "mismatched transaction" errors caused by the teleported,
* wire:ignore'd modal churning editor instances. Edits sync back to the
* Livewire `editorContent` property.
* Register the `fileEditor` Alpine component: a TipTap code editor dressed up
* like an IDE with a line-number gutter and a Ln/Col status bar. The editor is
* recreated fresh each time a file is loaded (content baked into the initial
* doc) to avoid ProseMirror "mismatched transaction" errors from the teleported
* wire:ignore'd modal. Edits sync back to the Livewire `editorContent` property.
*/
export function initializeFileEditorComponent() {
window.Alpine.data('fileEditor', function fileEditor() {
return {
editor: null,
language: 'plain text',
lineCount: 0,
activeLine: 0,
create(text, language) {
if (this.editor) {
this.editor.destroy();
@@ -42,6 +45,10 @@ export function initializeFileEditorComponent() {
}
// Clear any orphaned ProseMirror DOM left by a previous instance.
this.$refs.editor.innerHTML = '';
this.language = language || 'plain text';
this.lineCount = 0;
this.activeLine = 0;
this.editor = new Editor({
element: this.$refs.editor,
extensions: [CodeDocument, Text, CodeBlockLowlight.configure({ lowlight })],
@@ -51,9 +58,60 @@ export function initializeFileEditorComponent() {
content: docFor(text, language),
onUpdate: ({ editor }) => {
this.$wire.set('editorContent', editor.getText({ blockSeparator: '\n' }), false);
this.refresh();
},
onSelectionUpdate: () => this.refresh(),
});
this.refresh();
},
refresh() {
if (!this.editor || !this.$refs.gutter) {
return;
}
const full = this.editor.getText({ blockSeparator: '\n' });
const lines = full.length ? full.split('\n') : [''];
// Rebuild the gutter only when the line count changes.
if (lines.length !== this.lineCount) {
this.lineCount = lines.length;
this.$refs.gutter.innerHTML = Array.from(
{ length: this.lineCount },
(unused, i) => `<div>${i + 1}</div>`,
).join('');
this.activeLine = 0;
}
// Line/column from the current selection (offset by 1 for the
// code block's opening position).
const from = this.editor.state.selection.from;
const offset = Math.max(0, from - 1);
const before = full.slice(0, offset);
const line = before.split('\n').length;
const col = offset - before.lastIndexOf('\n');
this.setActive(line);
this.$refs.status.textContent = `Ln ${line}, Col ${col}`;
this.$refs.statusRight.textContent = `${this.lineCount} ${this.lineCount === 1 ? 'line' : 'lines'} · ${this.language} · spaces`;
},
setActive(line) {
if (line === this.activeLine) {
return;
}
const gutter = this.$refs.gutter;
const prev = gutter.querySelector('.is-active');
if (prev) {
prev.classList.remove('is-active');
}
const cur = gutter.children[line - 1];
if (cur) {
cur.classList.add('is-active');
}
this.activeLine = line;
},
load(detail) {
if (!detail) {
return;
@@ -61,6 +119,7 @@ export function initializeFileEditorComponent() {
this.create(detail.content ?? '', detail.language || null);
this.$nextTick(() => this.editor && this.editor.commands.focus('end'));
},
destroy() {
if (this.editor) {
this.editor.destroy();
@@ -274,7 +274,16 @@
wire:ignore protects the ProseMirror DOM from Livewire morphs. --}}
<div wire:ignore x-data="fileEditor" x-on:load-file-editor.window="load($event.detail)"
class="min-w-0">
<div x-ref="editor" class="file-editor"></div>
<div class="file-ide">
<div class="file-ide-gutter" x-ref="gutter" aria-hidden="true"></div>
<div class="file-ide-code">
<div x-ref="editor"></div>
</div>
</div>
<div class="file-ide-status">
<span x-ref="status">Ln 1, Col 1</span>
<span x-ref="statusRight" class="ml-auto"></span>
</div>
</div>
<div class="flex justify-end gap-2">
<x-forms.button type="button" wire:click="closeEditor"