Clide is being rebuilt as a Flutter desktop app. The Python Textual implementation moves wholesale into legacy/ rather than being deleted: its pane model, panel set, git skills, and panel communication design are real thought that should remain readable next to the new code while the rebuild finds its shape. Git's rename tracking preserves history, so `git log -- legacy/` still works. The Flutter rebuild lives at the repo root alongside a Go sidecar (the architecture claudian was heading toward, which folds into clide as a core component rather than a separate plugin project). Bootstrap of the new shape lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
8.8 KiB
Python
284 lines
8.8 KiB
Python
"""Editor pane component with syntax highlighting."""
|
|
|
|
from pathlib import Path
|
|
|
|
from textual.app import ComposeResult
|
|
from textual.containers import Vertical
|
|
from textual.message import Message
|
|
from textual.widgets import Static, TextArea
|
|
|
|
from clide.models.editor import CursorPosition, FileBuffer
|
|
|
|
# TypeScript highlight query (basic)
|
|
TYPESCRIPT_HIGHLIGHTS = """
|
|
(comment) @comment
|
|
(string) @string
|
|
(number) @number
|
|
(identifier) @variable
|
|
(type_identifier) @type
|
|
(property_identifier) @property
|
|
(function_declaration name: (identifier) @function)
|
|
(method_definition name: (property_identifier) @function.method)
|
|
(call_expression function: (identifier) @function.call)
|
|
(import_statement) @keyword
|
|
(export_statement) @keyword
|
|
["const" "let" "var" "function" "class" "interface" "type" "enum"
|
|
"if" "else" "for" "while" "do" "switch" "case" "default" "break"
|
|
"continue" "return" "throw" "try" "catch" "finally" "new" "delete"
|
|
"typeof" "instanceof" "in" "of" "async" "await" "yield" "import"
|
|
"export" "from" "as" "extends" "implements" "static" "public"
|
|
"private" "protected" "readonly" "abstract" "declare" "namespace"
|
|
"module" "require"] @keyword
|
|
["=>" "=" "+" "-" "*" "/" "%" "**" "++" "--" "==" "!=" "===" "!=="
|
|
"<" ">" "<=" ">=" "&&" "||" "!" "?" ":" "?." "??" "&" "|" "^" "~"
|
|
"<<" ">>" ">>>"] @operator
|
|
["(" ")" "[" "]" "{" "}"] @punctuation.bracket
|
|
["," "." ";" ":"] @punctuation.delimiter
|
|
(true) @constant.builtin
|
|
(false) @constant.builtin
|
|
(null) @constant.builtin
|
|
(undefined) @constant.builtin
|
|
"""
|
|
|
|
|
|
class EditorPane(Vertical):
|
|
"""Editor pane with TextArea and status bar."""
|
|
|
|
DEFAULT_CSS = """
|
|
EditorPane {
|
|
height: 100%;
|
|
}
|
|
|
|
EditorPane TextArea {
|
|
height: 1fr;
|
|
}
|
|
|
|
EditorPane .editor-status {
|
|
height: 1;
|
|
background: $surface;
|
|
padding: 0 1;
|
|
}
|
|
|
|
EditorPane .file-tab {
|
|
height: 1;
|
|
background: $panel;
|
|
}
|
|
|
|
EditorPane .modified {
|
|
color: $warning;
|
|
}
|
|
"""
|
|
|
|
class ContentChanged(Message):
|
|
"""Emitted when content changes."""
|
|
|
|
def __init__(self, path: Path, content: str) -> None:
|
|
self.path = path
|
|
self.content = content
|
|
super().__init__()
|
|
|
|
class CursorMoved(Message):
|
|
"""Emitted when cursor moves."""
|
|
|
|
def __init__(self, path: Path, line: int, column: int) -> None:
|
|
self.path = path
|
|
self.line = line
|
|
self.column = column
|
|
super().__init__()
|
|
|
|
class SaveRequested(Message):
|
|
"""Emitted when save is requested."""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
self.path = path
|
|
super().__init__()
|
|
|
|
class FileSaved(Message):
|
|
"""Emitted when file is saved."""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
self.path = path
|
|
super().__init__()
|
|
|
|
def __init__(self, buffer: FileBuffer | None = None, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self._buffer = buffer
|
|
self._typescript_registered = False
|
|
|
|
def on_mount(self) -> None:
|
|
"""Register additional languages on mount."""
|
|
self._register_typescript()
|
|
|
|
def _register_typescript(self) -> None:
|
|
"""Register TypeScript and TSX languages if available."""
|
|
if self._typescript_registered:
|
|
return
|
|
|
|
try:
|
|
import tree_sitter_typescript as tst
|
|
|
|
textarea = self.query_one(TextArea)
|
|
|
|
# Register TypeScript
|
|
try:
|
|
textarea.register_language(
|
|
"typescript",
|
|
tst.language_typescript(),
|
|
TYPESCRIPT_HIGHLIGHTS,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Register TSX (reuse TypeScript highlights)
|
|
try:
|
|
textarea.register_language(
|
|
"tsx",
|
|
tst.language_tsx(),
|
|
TYPESCRIPT_HIGHLIGHTS,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
self._typescript_registered = True
|
|
except ImportError:
|
|
pass
|
|
|
|
def compose(self) -> ComposeResult:
|
|
if self._buffer:
|
|
yield Static(self._buffer.display_name, classes="file-tab")
|
|
yield TextArea(
|
|
self._buffer.content,
|
|
language=self._buffer.language,
|
|
show_line_numbers=True,
|
|
)
|
|
yield Static(
|
|
self._get_status_text(),
|
|
classes="editor-status",
|
|
)
|
|
else:
|
|
yield Static("No file open", classes="file-tab")
|
|
yield TextArea(show_line_numbers=True)
|
|
yield Static("", classes="editor-status")
|
|
|
|
def load_buffer(self, buffer: FileBuffer) -> None: # noqa: ARG002
|
|
"""Load a file buffer into the editor."""
|
|
self._buffer = buffer
|
|
|
|
textarea = self.query_one(TextArea)
|
|
|
|
# Register TypeScript if needed
|
|
if buffer.language in ("typescript", "tsx"):
|
|
self._register_typescript()
|
|
|
|
textarea.load_text(buffer.content)
|
|
|
|
# Set language (will use JavaScript as fallback for TS if registration failed)
|
|
language = buffer.language
|
|
if language in ("typescript", "tsx") and language not in textarea.available_languages:
|
|
language = "javascript" # Fallback to JS highlighting
|
|
|
|
textarea.language = language
|
|
|
|
# Update tab
|
|
tab = self.query_one(".file-tab", Static)
|
|
tab.update(buffer.display_name)
|
|
|
|
# Update status
|
|
self._update_status()
|
|
|
|
# Set cursor position
|
|
if buffer.cursor:
|
|
textarea.cursor_location = (buffer.cursor.line, buffer.cursor.column)
|
|
|
|
def get_content(self) -> str:
|
|
"""Get current editor content."""
|
|
textarea = self.query_one(TextArea)
|
|
return textarea.text
|
|
|
|
def _get_status_text(self) -> str:
|
|
"""Generate status bar text."""
|
|
if not self._buffer:
|
|
return ""
|
|
|
|
line = self._buffer.cursor.line + 1 if self._buffer.cursor else 1
|
|
col = self._buffer.cursor.column + 1 if self._buffer.cursor else 1
|
|
lang = self._buffer.language or "plain text"
|
|
|
|
return f"Ln {line}, Col {col} | {lang}"
|
|
|
|
def _update_status(self) -> None:
|
|
"""Update status bar."""
|
|
status = self.query_one(".editor-status", Static)
|
|
status.update(self._get_status_text())
|
|
|
|
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
"""Handle text changes."""
|
|
if self._buffer:
|
|
self._buffer.content = event.text_area.text
|
|
self._buffer.is_modified = True
|
|
|
|
# Update tab to show modified indicator
|
|
tab = self.query_one(".file-tab", Static)
|
|
tab.update(self._buffer.display_name)
|
|
|
|
self.post_message(self.ContentChanged(self._buffer.path, event.text_area.text))
|
|
|
|
def on_text_area_selection_changed(self, event: TextArea.SelectionChanged) -> None:
|
|
"""Handle cursor movement."""
|
|
if self._buffer:
|
|
line, col = event.selection.end
|
|
# CursorPosition is frozen, so create new one
|
|
self._buffer.cursor = CursorPosition(line=line, column=col)
|
|
self._update_status()
|
|
self.post_message(self.CursorMoved(self._buffer.path, line, col))
|
|
|
|
@property
|
|
def current_file(self) -> Path | None:
|
|
"""Get currently open file path."""
|
|
return self._buffer.path if self._buffer else None
|
|
|
|
@property
|
|
def modified(self) -> bool:
|
|
"""Check if buffer has unsaved changes."""
|
|
return self._buffer.is_modified if self._buffer else False
|
|
|
|
def load_file(self, path: Path, goto_line: int | None = None) -> None:
|
|
"""Load a file from disk into the editor."""
|
|
from clide.services.file_service import FileService
|
|
|
|
try:
|
|
content = FileService.read_file(path)
|
|
except OSError as e:
|
|
# Show error in editor
|
|
content = f"# Error loading file\n# {e}"
|
|
|
|
language = FileService.detect_language(path)
|
|
|
|
buffer = FileBuffer(
|
|
path=path,
|
|
content=content,
|
|
language=language,
|
|
is_modified=False,
|
|
)
|
|
self.load_buffer(buffer)
|
|
|
|
if goto_line is not None:
|
|
textarea = self.query_one(TextArea)
|
|
textarea.cursor_location = (goto_line - 1, 0)
|
|
|
|
def save(self) -> bool:
|
|
"""Save current buffer to disk."""
|
|
if not self._buffer:
|
|
return False
|
|
|
|
from clide.services.file_service import FileService
|
|
|
|
success = FileService.write_file(self._buffer.path, self._buffer.content)
|
|
if success:
|
|
self._buffer.is_modified = False
|
|
# Update tab
|
|
tab = self.query_one(".file-tab", Static)
|
|
tab.update(self._buffer.display_name)
|
|
self.post_message(self.FileSaved(self._buffer.path))
|
|
return success
|