1use oxc_allocator::Allocator;
12use oxc_codegen::Codegen;
13use oxc_parser::Parser;
14use oxc_span::SourceType;
15use ts_rs::{Config as TsConfig, TS};
16
17use fresh_core::api::{
18 ActionPopupAction, ActionPopupOptions, ActionSpec, AnimationRect, BackgroundProcessResult,
19 BufferGroupResult, BufferInfo, BufferSavedDiff, CompositeHunk, CompositeLayoutConfig,
20 CompositePaneStyle, CompositeSourceConfig, CreateCompositeBufferOptions, CreateTerminalOptions,
21 CreateVirtualBufferInExistingSplitOptions, CreateVirtualBufferInSplitOptions,
22 CreateVirtualBufferOptions, CursorInfo, DirEntry, FormatterPackConfig, GrammarInfoSnapshot,
23 GrepMatch, JsDiagnostic, JsPosition, JsRange, JsTextPropertyEntry, KeyEventPayload,
24 LanguagePackConfig, LayoutHints, LspServerPackConfig, OverlayColorSpec, OverlayOptions,
25 PluginAnimationEdge, PluginAnimationKind, ProcessLimitsPackConfig, ReplaceResult, ScreenSize,
26 SearchTakeResult, SpawnResult, SplitSnapshot, TerminalResult, TextPropertiesAtCursor,
27 TokenColor, TsHighlightSpan, ViewTokenStyle, ViewTokenWire, ViewTokenWireKind, ViewportInfo,
28 VirtualBufferResult, WindowInfo,
29};
30use fresh_core::command::Suggestion;
31use fresh_core::file_explorer::{
32 FileExplorerDecoration, FileExplorerLeadingSlot, FileExplorerSlotEntry, FileExplorerTooltip,
33 FileExplorerTrailingSlot,
34};
35use fresh_core::text_property::InlineOverlay;
36
37fn get_type_decl(type_name: &str) -> Option<String> {
42 let cfg = TsConfig::default();
43 match type_name {
46 "AnimationRect" => Some(AnimationRect::decl(&cfg)),
48 "PluginAnimationEdge" => Some(PluginAnimationEdge::decl(&cfg)),
49 "PluginAnimationKind" => Some(PluginAnimationKind::decl(&cfg)),
50
51 "BufferInfo" => Some(BufferInfo::decl(&cfg)),
53 "WindowInfo" => Some(WindowInfo::decl(&cfg)),
54 "CursorInfo" => Some(CursorInfo::decl(&cfg)),
55 "ViewportInfo" => Some(ViewportInfo::decl(&cfg)),
56 "ScreenSize" => Some(ScreenSize::decl(&cfg)),
57 "KeyEventPayload" => Some(KeyEventPayload::decl(&cfg)),
58 "SplitSnapshot" => Some(SplitSnapshot::decl(&cfg)),
59 "ActionSpec" => Some(ActionSpec::decl(&cfg)),
60 "BufferSavedDiff" => Some(BufferSavedDiff::decl(&cfg)),
61 "LayoutHints" => Some(LayoutHints::decl(&cfg)),
62
63 "SpawnResult" => Some(SpawnResult::decl(&cfg)),
65 "BackgroundProcessResult" => Some(BackgroundProcessResult::decl(&cfg)),
66
67 "GrepMatch" => Some(GrepMatch::decl(&cfg)),
69 "ReplaceResult" => Some(ReplaceResult::decl(&cfg)),
70 "SearchTakeResult" => Some(SearchTakeResult::decl(&cfg)),
71 "SearchHandle" => Some(
74 "interface SearchHandle { searchId: number; take(): SearchTakeResult; cancel(): void; }".to_string(),
75 ),
76
77 "TerminalResult" => Some(TerminalResult::decl(&cfg)),
79 "CreateTerminalOptions" => Some(CreateTerminalOptions::decl(&cfg)),
80 "CreateWindowWithTerminalOptions" => {
81 Some(fresh_core::api::CreateWindowWithTerminalOptions::decl(&cfg))
82 }
83 "SessionWithTerminalResult" => {
84 Some(fresh_core::api::SessionWithTerminalResult::decl(&cfg))
85 }
86
87 "TsCompositeLayoutConfig" | "CompositeLayoutConfig" => {
89 Some(CompositeLayoutConfig::decl(&cfg))
90 }
91 "TsCompositeSourceConfig" | "CompositeSourceConfig" => {
92 Some(CompositeSourceConfig::decl(&cfg))
93 }
94 "TsCompositePaneStyle" | "CompositePaneStyle" => Some(CompositePaneStyle::decl(&cfg)),
95 "TsCompositeHunk" | "CompositeHunk" => Some(CompositeHunk::decl(&cfg)),
96 "TsCreateCompositeBufferOptions" | "CreateCompositeBufferOptions" => {
97 Some(CreateCompositeBufferOptions::decl(&cfg))
98 }
99
100 "ViewTokenWireKind" => Some(ViewTokenWireKind::decl(&cfg)),
102 "TokenColor" => Some(TokenColor::decl(&cfg)),
103 "ViewTokenStyle" => Some(ViewTokenStyle::decl(&cfg)),
104 "ViewTokenWire" => Some(ViewTokenWire::decl(&cfg)),
105
106 "TsActionPopupAction" | "ActionPopupAction" => Some(ActionPopupAction::decl(&cfg)),
108 "ActionPopupOptions" => Some(ActionPopupOptions::decl(&cfg)),
109 "TsLspMenuItem" | "LspMenuItem" => Some(fresh_core::api::LspMenuItem::decl(&cfg)),
110 "TsHighlightSpan" => Some(TsHighlightSpan::decl(&cfg)),
111 "FileExplorerDecoration" => Some(FileExplorerDecoration::decl(&cfg)),
112 "FileExplorerSlotEntry" => Some(FileExplorerSlotEntry::decl(&cfg)),
113 "FileExplorerLeadingSlot" => Some(FileExplorerLeadingSlot::decl(&cfg)),
114 "FileExplorerTrailingSlot" => Some(FileExplorerTrailingSlot::decl(&cfg)),
115 "FileExplorerTooltip" => Some(FileExplorerTooltip::decl(&cfg)),
116
117 "TextPropertyEntry" | "JsTextPropertyEntry" => Some(JsTextPropertyEntry::decl(&cfg)),
119 "CreateVirtualBufferOptions" => Some(CreateVirtualBufferOptions::decl(&cfg)),
120 "CreateVirtualBufferInSplitOptions" => Some(CreateVirtualBufferInSplitOptions::decl(&cfg)),
121 "CreateVirtualBufferInExistingSplitOptions" => {
122 Some(CreateVirtualBufferInExistingSplitOptions::decl(&cfg))
123 }
124
125 "TextPropertiesAtCursor" => Some(TextPropertiesAtCursor::decl(&cfg)),
127 "VirtualBufferResult" => Some(VirtualBufferResult::decl(&cfg)),
128 "BufferGroupResult" => Some(BufferGroupResult::decl(&cfg)),
129
130 "PromptSuggestion" | "Suggestion" => Some(Suggestion::decl(&cfg)),
132 "DirEntry" => Some(DirEntry::decl(&cfg)),
133
134 "JsDiagnostic" => Some(JsDiagnostic::decl(&cfg)),
136 "JsRange" => Some(JsRange::decl(&cfg)),
137 "JsPosition" => Some(JsPosition::decl(&cfg)),
138
139 "GrammarInfoSnapshot" => Some(GrammarInfoSnapshot::decl(&cfg)),
141
142 "LanguagePackConfig" => Some(LanguagePackConfig::decl(&cfg)),
144 "LspServerPackConfig" => Some(LspServerPackConfig::decl(&cfg)),
145 "ProcessLimitsPackConfig" => Some(ProcessLimitsPackConfig::decl(&cfg)),
146 "FormatterPackConfig" => Some(FormatterPackConfig::decl(&cfg)),
147
148 "OverlayOptions" => Some(OverlayOptions::decl(&cfg)),
150 "OverlayColorSpec" => Some(OverlayColorSpec::decl(&cfg)),
151 "InlineOverlay" => Some(InlineOverlay::decl(&cfg)),
152 "OffsetUnit" => Some(fresh_core::text_property::OffsetUnit::decl(&cfg)),
153 "StyledSegment" => Some(fresh_core::text_property::StyledSegment::decl(&cfg)),
154 "StyledText" => Some(fresh_core::api::StyledText::decl(&cfg)),
155
156 "WidgetSpec" => Some(fresh_core::api::WidgetSpec::decl(&cfg)),
159 "HintEntry" => Some(fresh_core::api::HintEntry::decl(&cfg)),
160 "ButtonKind" => Some(fresh_core::api::ButtonKind::decl(&cfg)),
161 "WidgetAction" => Some(fresh_core::api::WidgetAction::decl(&cfg)),
162 "WidgetMutation" => Some(fresh_core::api::WidgetMutation::decl(&cfg)),
163 "TreeNode" => Some(fresh_core::api::TreeNode::decl(&cfg)),
164
165 "AuthorityPayload" => Some(AUTHORITY_PAYLOAD_DECL.to_string()),
171
172 "RemoteAgentSpec" => Some(REMOTE_AGENT_SPEC_DECL.to_string()),
178
179 "RemoteIndicatorStatePayload" => Some(REMOTE_INDICATOR_STATE_DECL.to_string()),
185
186 _ => None,
187 }
188}
189
190const AUTHORITY_PAYLOAD_DECL: &str = r#"type AuthorityFilesystem = { kind: "local" };
197
198type AuthoritySpawner =
199 | { kind: "local" }
200 | {
201 kind: "docker-exec";
202 container_id: string;
203 user?: string | null;
204 workspace?: string | null;
205 env?: [string, string][];
206 };
207
208type AuthorityTerminalWrapper =
209 | { kind: "host-shell" }
210 | {
211 kind: "explicit";
212 command: string;
213 args: string[];
214 manages_cwd?: boolean;
215 };
216
217type AuthorityPayload = {
218 filesystem: AuthorityFilesystem;
219 spawner: AuthoritySpawner;
220 terminal_wrapper: AuthorityTerminalWrapper;
221 display_label?: string;
222 /**
223 * Optional host↔remote workspace path mapping. The dev-container
224 * authority sets both roots (editor.getCwd() on host;
225 * remoteWorkspaceFolder on container) so LSP URIs translate at the
226 * host/container boundary. Local and SSH authorities omit it.
227 */
228 path_translation?: PathTranslationSpec;
229};
230type PathTranslationSpec = {
231 host_root: string;
232 remote_root: string;
233};"#;
234
235const REMOTE_AGENT_SPEC_DECL: &str = r#"type RemoteAgentTransport = {
242 kind: "kubectl-exec";
243 /** kubeconfig context to select (`--context`); omit for the current one. */
244 context?: string | null;
245 namespace: string;
246 pod: string;
247 /** Target container in a multi-container pod (`-c`). */
248 container?: string | null;
249 /** Pod-side workspace root the terminal opens in. */
250 workspace?: string | null;
251} | {
252 kind: "ssh";
253 /** Login user. Optional — omit for `host` / `ssh://host`, letting ssh pick
254 * the user from its own config or the current local user. */
255 user?: string | null;
256 host: string;
257 port?: number | null;
258 identity_file?: string | null;
259 /** Remote directory to root the session at. */
260 remote_path?: string | null;
261 /** Extra `ssh` arguments (e.g. `-J jump`, `-o ProxyCommand=…`) applied to
262 * every ssh invocation for this session. */
263 extra_args?: string[];
264};
265
266type RemoteAgentSpec = {
267 transport: RemoteAgentTransport;
268 /**
269 * Captured in-pod env (PATH/HOME/LANG/…) applied to LSP spawns and
270 * binary-presence probes. Omit when no probe was run.
271 */
272 base_env?: [string, string][];
273 /**
274 * When true, attach as a NEW window (born-attached, coexisting with the
275 * existing windows) instead of the default global restart that replaces the
276 * whole editor's authority. The Orchestrator sets this so a cloud session is
277 * a real session row beside local ones.
278 */
279 window?: boolean;
280 /** Window label (window mode only). Omit to use the transport's display. */
281 label?: string;
282 /** Optional agent argv for the new window's seed terminal (window mode). */
283 command?: string[];
284};"#;
285
286const REMOTE_INDICATOR_STATE_DECL: &str = r#"type RemoteIndicatorStatePayload =
291 | { kind: "local" }
292 | { kind: "connecting"; label?: string | null }
293 | { kind: "connected"; label?: string | null }
294 | { kind: "failed_attach"; error?: string | null }
295 | { kind: "disconnected"; label?: string | null };"#;
296
297const DEPENDENCY_TYPES: &[&str] = &[
301 "TextPropertyEntry", "TsCompositeLayoutConfig", "TsCompositeSourceConfig", "TsCompositePaneStyle", "TsCompositeHunk", "TsCreateCompositeBufferOptions", "ViewportInfo", "ScreenSize", "KeyEventPayload", "SplitSnapshot", "LayoutHints", "ViewTokenWire", "ViewTokenWireKind", "TokenColor", "ViewTokenStyle", "PromptSuggestion", "DirEntry", "BufferInfo", "WindowInfo", "JsDiagnostic", "JsRange", "JsPosition", "ActionSpec", "TsActionPopupAction", "ActionPopupOptions", "TsLspMenuItem", "FileExplorerDecoration", "FileExplorerSlotEntry", "FileExplorerLeadingSlot", "FileExplorerTrailingSlot", "FileExplorerTooltip", "FormatterPackConfig", "ProcessLimitsPackConfig", "TerminalResult", "CreateWindowWithTerminalOptions", "SessionWithTerminalResult", "CreateTerminalOptions", "CursorInfo", "OverlayOptions", "OverlayColorSpec", "InlineOverlay", "OffsetUnit", "StyledSegment", "GrammarInfoSnapshot", "AnimationRect", "PluginAnimationEdge", "PluginAnimationKind", "HintEntry", "ButtonKind", "TreeNode", "WidgetSpec", "WidgetAction", "WidgetMutation", "SearchTakeResult",
357 "SearchHandle",
358 "ReplaceResult",
360];
361
362pub fn collect_ts_types() -> String {
367 use crate::backend::quickjs_backend::JSEDITORAPI_REFERENCED_TYPES;
368
369 let mut types = Vec::new();
370 let mut included_decls = std::collections::HashSet::new();
373
374 for type_name in DEPENDENCY_TYPES {
376 if let Some(decl) = get_type_decl(type_name) {
377 if included_decls.insert(decl.clone()) {
378 types.push(decl);
379 }
380 }
381 }
382
383 for type_name in JSEDITORAPI_REFERENCED_TYPES {
385 if let Some(decl) = get_type_decl(type_name) {
386 if included_decls.insert(decl.clone()) {
387 types.push(decl);
388 }
389 } else {
390 eprintln!(
392 "Warning: Type '{}' is referenced in API but not registered in get_type_decl()",
393 type_name
394 );
395 }
396 }
397
398 types.join("\n\n")
399}
400
401pub fn validate_typescript(source: &str) -> Result<(), String> {
405 let allocator = Allocator::default();
406 let source_type = SourceType::d_ts();
407
408 let parser_ret = Parser::new(&allocator, source, source_type).parse();
409
410 if parser_ret.errors.is_empty() {
411 Ok(())
412 } else {
413 let errors: Vec<String> = parser_ret
414 .errors
415 .iter()
416 .map(|e: &oxc_diagnostics::OxcDiagnostic| e.to_string())
417 .collect();
418 Err(format!("TypeScript parse errors:\n{}", errors.join("\n")))
419 }
420}
421
422pub fn format_typescript(source: &str) -> String {
427 let allocator = Allocator::default();
428 let source_type = SourceType::d_ts();
429
430 let parser_ret = Parser::new(&allocator, source, source_type).parse();
431
432 if !parser_ret.errors.is_empty() {
433 return source.to_string();
435 }
436
437 Codegen::new().build(&parser_ret.program).code
439}
440
441pub fn write_fresh_dts() -> Result<(), String> {
446 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
447
448 let ts_types = collect_ts_types();
449
450 let plugin_api_trailer = r#"
459
460/**
461 * Typed overload of `editor.getPluginApi`. When the caller passes a
462 * key that some loaded plugin declared in `FreshPluginRegistry`, the
463 * return type is narrowed to that plugin's API. Unknown names fall
464 * through to the untyped `unknown | null` signature.
465 */
466interface EditorAPI {
467 getPluginApi<K extends keyof FreshPluginRegistry>(name: K): FreshPluginRegistry[K] | null;
468}
469
470/**
471 * Typed overload of `editor.defineConfigEnum`. The macro-generated
472 * signature can't express `<E extends string>` propagating from the
473 * `values` array into the return type, so it's declared here. Use
474 * `as const` on the `values` array to get a literal-union return:
475 *
476 * ```ts
477 * const mode = editor.defineConfigEnum("mode", {
478 * values: ["normal", "insert"] as const,
479 * default: "normal",
480 * });
481 * mode; // typed as "normal" | "insert"
482 * ```
483 *
484 * Typed overload of `editor.getPluginConfig`. Plugins that declared
485 * their fields via `defineConfigX` can pass the shape type explicitly:
486 * `editor.getPluginConfig<{ autoEnable: boolean; ... }>()`. Without
487 * the generic, falls back to `unknown`.
488 */
489interface EditorAPI {
490 defineConfigEnum<E extends string>(
491 name: string,
492 options: { values: readonly E[]; default: NoInfer<E>; description?: string },
493 ): E;
494 getPluginConfig<T = unknown>(): T;
495}
496
497/**
498 * Maps every hook event name to its payload type.
499 *
500 * Payloads match the flat JSON produced by `hook_args_to_json` on the Rust
501 * side (`HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
502 * fields only). The TypeScript types here are derived directly from the Rust
503 * field definitions and must be kept in sync with `fresh-core/src/hooks.rs`.
504 *
505 * `action` in `pre_command`/`post_command` is the serde JSON of the `Action`
506 * enum: unit variants serialize as a plain string (e.g. `"MoveLeft"`),
507 * tuple variants as a single-key object (e.g. `{"InsertChar": "a"}`).
508 */
509interface HookEventMap {
510 // ── lifecycle ────────────────────────────────────────────────────────────
511 editor_initialized: Record<string, never>;
512 plugins_loaded: Record<string, never>;
513 ready: Record<string, never>;
514 focus_gained: Record<string, never>;
515 authority_changed: { label: string };
516 trust_changed: { level: "trusted" | "restricted" | "blocked" };
517
518 // ── buffer lifecycle ─────────────────────────────────────────────────────
519 buffer_activated: { buffer_id: number };
520 buffer_deactivated: { buffer_id: number };
521 buffer_closed: { buffer_id: number };
522
523 // ── file I/O ─────────────────────────────────────────────────────────────
524 before_file_open: { path: string };
525 after_file_open: { path: string; buffer_id: number };
526 before_file_save: { path: string; buffer_id: number };
527 after_file_save: { path: string; buffer_id: number };
528 /**
529 * Fired by the file explorer after a paste/duplicate/etc. mutates
530 * the filesystem without going through a buffer save. Plugins that
531 * surface FS-derived state (git status badges, etc.) should
532 * subscribe in addition to `after_file_save` to refresh on
533 * explorer-driven changes too.
534 */
535 after_file_explorer_change: { path: string };
536
537 // ── text edits ───────────────────────────────────────────────────────────
538 before_insert: { buffer_id: number; position: number; text: string };
539 after_insert: {
540 buffer_id: number;
541 position: number;
542 text: string;
543 affected_start: number;
544 affected_end: number;
545 start_line: number;
546 end_line: number;
547 lines_added: number;
548 };
549 before_delete: { buffer_id: number; start: number; end: number };
550 after_delete: {
551 buffer_id: number;
552 start: number;
553 end: number;
554 deleted_text: string;
555 affected_start: number;
556 deleted_len: number;
557 start_line: number;
558 end_line: number;
559 lines_removed: number;
560 };
561
562 // ── cursor & viewport ────────────────────────────────────────────────────
563 cursor_moved: {
564 buffer_id: number;
565 cursor_id: number;
566 old_position: number;
567 new_position: number;
568 line: number;
569 text_properties: Record<string, unknown>[];
570 };
571 viewport_changed: {
572 split_id: number;
573 buffer_id: number;
574 top_byte: number;
575 top_line: number | null;
576 width: number;
577 height: number;
578 };
579
580 // ── rendering ────────────────────────────────────────────────────────────
581 render_start: { buffer_id: number };
582 render_line: {
583 buffer_id: number;
584 line_number: number;
585 byte_start: number;
586 byte_end: number;
587 content: string;
588 };
589 lines_changed: {
590 buffer_id: number;
591 lines: { line_number: number; byte_start: number; byte_end: number; content: string }[];
592 };
593 view_transform_request: {
594 buffer_id: number;
595 split_id: number;
596 viewport_start: number;
597 viewport_end: number;
598 tokens: ViewTokenWire[];
599 cursor_positions: number[];
600 };
601
602 // ── commands ─────────────────────────────────────────────────────────────
603 pre_command: { action: string | Record<string, unknown> };
604 post_command: { action: string | Record<string, unknown> };
605 idle: { milliseconds: number };
606 resize: { width: number; height: number };
607
608 // ── prompts ──────────────────────────────────────────────────────────────
609 prompt_changed: { prompt_type: string; input: string };
610 prompt_confirmed: { prompt_type: string; input: string; selected_index: number | null };
611 prompt_cancelled: { prompt_type: string; input: string };
612 prompt_selection_changed: { prompt_type: string; selected_index: number };
613
614 // ── mouse ────────────────────────────────────────────────────────────────
615 mouse_click: MouseClickHookArgs;
616 mouse_move: { column: number; row: number; content_x: number; content_y: number };
617 mouse_scroll: { buffer_id: number; delta: number; col: number; row: number };
618
619 // ── LSP ──────────────────────────────────────────────────────────────────
620 diagnostics_updated: { uri: string; count: number };
621 lsp_references: {
622 symbol: string;
623 locations: { file: string; line: number; column: number }[];
624 };
625 lsp_implementation: {
626 symbol: string;
627 locations: { file: string; line: number; column: number }[];
628 };
629 lsp_server_request: {
630 language: string;
631 method: string;
632 server_command: string;
633 params: string | null;
634 };
635 lsp_server_error: {
636 language: string;
637 server_command: string;
638 error_type: string;
639 message: string;
640 };
641 lsp_status_clicked: {
642 language: string;
643 has_error: boolean;
644 missing_servers: string[];
645 user_dismissed: boolean;
646 };
647
648 // ── UI events ────────────────────────────────────────────────────────────
649 action_popup_result: { popup_id: string; action_id: string };
650 /**
651 * User clicked a plugin-registered status-bar token. Subscribers
652 * filter by `plugin_name` + `token_name`. Use this to re-open a
653 * deferred prompt or surface the relevant settings UI for whatever
654 * the token represents (e.g. trust chip → trust-elevation popup).
655 */
656 status_bar_token_clicked: { plugin_name: string; token_name: string };
657 process_output: { process_id: number; data: string };
658 language_changed: { buffer_id: number; language: string };
659 theme_inspect_key: { theme_name: string; key: string };
660 keyboard_shortcuts: { bindings: { key: string; action: string }[] };
661
662 // ── PTY terminals (see crates/fresh-core/src/hooks.rs) ───────────────────
663 // `window_id` is the editor window owning the terminal (== session id),
664 // so a plugin can attribute output to a session: output from ANY terminal
665 // in the window counts, and it fires on every PTY read (in-place redraws
666 // and carriage-return progress bars register, not just newlines).
667 terminal_output: { terminal_id: number; window_id: number; last_line: string };
668 terminal_exit: { terminal_id: number; window_id: number; exit_code: number | null };
669
670 // ── filesystem watching (watchPath plugin API) ────────────────────────────
671 path_changed: {
672 handle: number;
673 path: string;
674 /** "modify" | "create" | "delete" | "rename" | "other" */
675 kind: string;
676 };
677
678 // ── editor sessions (Orchestrator; see orchestrator-sessions-design.md) ────────
679 window_created: { id: number; label: string; root: string };
680 window_closed: { id: number };
681 active_window_changed: { previous_id: number | null; active_id: number };
682
683 // ── widget runtime ───────────────────────────────────────────────────────
684 /**
685 * A widget mounted via `editor.mountWidgetPanel` emitted a
686 * semantic event. Fired when the host's hit-test routes a mouse
687 * click to a `Toggle` / `Button` widget node within a mounted
688 * widget panel. See `docs/internal/plugin-widget-library-design.md`.
689 *
690 * Panel ids are plugin-local: the host keys panels by
691 * (plugin, id) and delivers each event only to the plugin that
692 * owns the panel, so ids never need to be globally unique.
693 * Routing is by `panel_id` (matches the id the plugin allocated
694 * at mount time) plus `widget_key` (the stable `key` set on the
695 * widget spec node, or empty when the spec did not assign one).
696 *
697 * `event_type` and `payload` shapes:
698 * * Toggle: `event_type = "toggle"`, `payload = { checked: <new> }`.
699 * * Button: `event_type = "activate"`, `payload = {}`.
700 */
701 widget_event: {
702 panel_id: number;
703 widget_key: string;
704 event_type: string;
705 payload: Record<string, unknown>;
706 };
707}
708
709/**
710 * Typed overloads of `editor.on` / `editor.off`.
711 *
712 * When the event name is a key of `HookEventMap` the handler receives a
713 * fully-typed payload — TypeScript will flag misspelled field accesses at
714 * compile time. Unknown event names fall through to the untyped base
715 * signatures in the EditorAPI interface.
716 *
717 * Both function-value and handler-name forms are supported:
718 *
719 * ```ts
720 * editor.on("buffer_activated", (args) => { /* args.buffer_id is number *\/ });
721 * editor.on("buffer_activated", "myHandler"); // registerHandler("myHandler", fn)
722 * ```
723 */
724interface EditorAPI {
725 on<K extends keyof HookEventMap>(
726 eventName: K,
727 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
728 ): void;
729 on<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
730 off<K extends keyof HookEventMap>(
731 eventName: K,
732 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
733 ): void;
734 off<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
735 /**
736 * Create a buffer group: multiple panels appearing as one tab.
737 * This is an async runtime binding (not a direct #[qjs] method).
738 */
739 createBufferGroup(
740 name: string,
741 mode: string,
742 layout: unknown,
743 ): Promise<BufferGroupResult>;
744}
745"#;
746
747 let content = format!(
748 "{}\n{}\n{}{}",
749 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API, plugin_api_trailer
750 );
751
752 validate_typescript(&content)?;
754
755 let formatted = format_typescript(&content);
757
758 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
760 let output_path = std::path::Path::new(&manifest_dir)
761 .parent() .and_then(|p| p.parent()) .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
764 .unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
765
766 let should_write = match std::fs::read_to_string(&output_path) {
768 Ok(existing) => existing != formatted,
769 Err(_) => true,
770 };
771
772 if should_write {
773 if let Some(parent) = output_path.parent() {
774 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
775 }
776 std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
777 }
778
779 Ok(())
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 #[test]
789 #[ignore]
790 fn write_fresh_dts_file() {
791 write_fresh_dts().expect("Failed to write fresh.d.ts");
793 println!("Successfully generated, validated, and formatted fresh.d.ts");
794 }
795
796 #[test]
800 #[ignore]
801 fn type_check_plugins() {
802 let tsc_check = std::process::Command::new("tsc").arg("--version").output();
804
805 match tsc_check {
806 Ok(output) if output.status.success() => {
807 println!(
808 "Found tsc: {}",
809 String::from_utf8_lossy(&output.stdout).trim()
810 );
811 }
812 _ => {
813 println!("tsc not found in PATH, skipping type check test");
814 return;
815 }
816 }
817
818 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
820 let script_path = std::path::Path::new(&manifest_dir)
821 .parent()
822 .and_then(|p| p.parent())
823 .map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
824 .expect("Failed to find check-types.sh");
825
826 println!("Running type check script: {}", script_path.display());
827
828 let output = std::process::Command::new("bash")
830 .arg(&script_path)
831 .output()
832 .expect("Failed to run check-types.sh");
833
834 let stdout = String::from_utf8_lossy(&output.stdout);
835 let stderr = String::from_utf8_lossy(&output.stderr);
836
837 println!("stdout:\n{}", stdout);
838 if !stderr.is_empty() {
839 println!("stderr:\n{}", stderr);
840 }
841
842 if stdout.contains("had type errors") || !output.status.success() {
844 panic!(
845 "TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
846 );
847 }
848
849 println!("All plugins type check successfully!");
850 }
851
852 #[test]
857 fn test_get_type_decl_returns_all_expected_types() {
858 let expected_types = vec![
859 "BufferInfo",
860 "WindowInfo",
861 "CursorInfo",
862 "ViewportInfo",
863 "ScreenSize",
864 "KeyEventPayload",
865 "SplitSnapshot",
866 "ActionSpec",
867 "BufferSavedDiff",
868 "LayoutHints",
869 "SpawnResult",
870 "BackgroundProcessResult",
871 "TerminalResult",
872 "CreateTerminalOptions",
873 "CreateWindowWithTerminalOptions",
874 "SessionWithTerminalResult",
875 "TsCompositeLayoutConfig",
876 "TsCompositeSourceConfig",
877 "TsCompositePaneStyle",
878 "TsCompositeHunk",
879 "TsCreateCompositeBufferOptions",
880 "ViewTokenWireKind",
881 "TokenColor",
882 "ViewTokenStyle",
883 "ViewTokenWire",
884 "TsActionPopupAction",
885 "ActionPopupOptions",
886 "TsHighlightSpan",
887 "FileExplorerDecoration",
888 "TextPropertyEntry",
889 "CreateVirtualBufferOptions",
890 "CreateVirtualBufferInSplitOptions",
891 "CreateVirtualBufferInExistingSplitOptions",
892 "TextPropertiesAtCursor",
893 "VirtualBufferResult",
894 "PromptSuggestion",
895 "DirEntry",
896 "JsDiagnostic",
897 "JsRange",
898 "JsPosition",
899 "LanguagePackConfig",
900 "LspServerPackConfig",
901 "ProcessLimitsPackConfig",
902 "FormatterPackConfig",
903 ];
904
905 for type_name in &expected_types {
906 assert!(
907 get_type_decl(type_name).is_some(),
908 "get_type_decl should return a declaration for '{}'",
909 type_name
910 );
911 }
912 }
913
914 #[test]
915 fn test_get_type_decl_aliases_resolve_same() {
916 let alias_pairs = vec![
918 ("CompositeHunk", "TsCompositeHunk"),
919 ("CompositeLayoutConfig", "TsCompositeLayoutConfig"),
920 ("CompositeSourceConfig", "TsCompositeSourceConfig"),
921 ("CompositePaneStyle", "TsCompositePaneStyle"),
922 (
923 "CreateCompositeBufferOptions",
924 "TsCreateCompositeBufferOptions",
925 ),
926 ("ActionPopupAction", "TsActionPopupAction"),
927 ("Suggestion", "PromptSuggestion"),
928 ("JsTextPropertyEntry", "TextPropertyEntry"),
929 ];
930
931 for (rust_name, ts_name) in &alias_pairs {
932 let rust_decl = get_type_decl(rust_name);
933 let ts_decl = get_type_decl(ts_name);
934 assert!(
935 rust_decl.is_some(),
936 "get_type_decl should handle Rust name '{}'",
937 rust_name
938 );
939 assert_eq!(
940 rust_decl, ts_decl,
941 "Alias '{}' and '{}' should produce identical declarations",
942 rust_name, ts_name
943 );
944 }
945 }
946
947 #[test]
948 fn test_terminal_types_exist() {
949 let terminal_result = get_type_decl("TerminalResult");
950 assert!(
951 terminal_result.is_some(),
952 "TerminalResult should be defined"
953 );
954 let decl = terminal_result.unwrap();
955 assert!(
956 decl.contains("bufferId"),
957 "TerminalResult should have bufferId field"
958 );
959 assert!(
960 decl.contains("terminalId"),
961 "TerminalResult should have terminalId field"
962 );
963 assert!(
964 decl.contains("splitId"),
965 "TerminalResult should have splitId field"
966 );
967
968 let terminal_opts = get_type_decl("CreateTerminalOptions");
969 assert!(
970 terminal_opts.is_some(),
971 "CreateTerminalOptions should be defined"
972 );
973 }
974
975 #[test]
976 fn test_cursor_info_type_exists() {
977 let cursor_info = get_type_decl("CursorInfo");
978 assert!(cursor_info.is_some(), "CursorInfo should be defined");
979 let decl = cursor_info.unwrap();
980 assert!(
981 decl.contains("position"),
982 "CursorInfo should have position field"
983 );
984 assert!(
985 decl.contains("selection"),
986 "CursorInfo should have selection field"
987 );
988 }
989
990 #[test]
991 fn test_collect_ts_types_no_duplicates() {
992 let output = collect_ts_types();
993 let lines: Vec<&str> = output.lines().collect();
994
995 let mut declarations = std::collections::HashSet::new();
997 for line in &lines {
998 let trimmed = line.trim();
999 if trimmed.starts_with("type ") && trimmed.contains('=') {
1001 let name = trimmed
1002 .strip_prefix("type ")
1003 .unwrap()
1004 .split(|c: char| c == '=' || c.is_whitespace())
1005 .next()
1006 .unwrap();
1007 assert!(
1008 declarations.insert(name.to_string()),
1009 "Duplicate type declaration found: '{}'",
1010 name
1011 );
1012 }
1013 }
1014 }
1015
1016 #[test]
1017 fn test_collect_ts_types_includes_dependency_types() {
1018 let output = collect_ts_types();
1019 let required_types = [
1020 "TextPropertyEntry",
1021 "TsCompositeLayoutConfig",
1022 "TsCompositeSourceConfig",
1023 "TsCompositePaneStyle",
1024 "TsCompositeHunk",
1025 "TsCreateCompositeBufferOptions",
1026 "PromptSuggestion",
1027 "BufferInfo",
1028 "CursorInfo",
1029 "TerminalResult",
1030 "CreateTerminalOptions",
1031 ];
1032
1033 for type_name in &required_types {
1034 assert!(
1035 output.contains(type_name),
1036 "collect_ts_types output should contain type '{}'",
1037 type_name
1038 );
1039 }
1040 }
1041
1042 #[test]
1043 fn test_generated_dts_validates_as_typescript() {
1044 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
1045
1046 let ts_types = collect_ts_types();
1047 let content = format!(
1048 "{}\n{}\n{}",
1049 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
1050 );
1051
1052 validate_typescript(&content).expect("Generated TypeScript should be syntactically valid");
1053 }
1054
1055 #[test]
1056 fn test_generated_dts_no_undefined_type_references() {
1057 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
1058
1059 let ts_types = collect_ts_types();
1060 let content = format!(
1061 "{}\n{}\n{}",
1062 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
1063 );
1064
1065 let mut defined_types = std::collections::HashSet::new();
1067 for builtin in &[
1069 "number",
1070 "string",
1071 "boolean",
1072 "void",
1073 "unknown",
1074 "null",
1075 "undefined",
1076 "Record",
1077 "Array",
1078 "Promise",
1079 "ProcessHandle",
1080 "PromiseLike",
1081 "BufferId",
1082 "SplitId",
1083 "EditorAPI",
1084 ] {
1085 defined_types.insert(builtin.to_string());
1086 }
1087
1088 for line in content.lines() {
1090 let trimmed = line.trim();
1091 if trimmed.starts_with("type ") && trimmed.contains('=') {
1092 if let Some(name) = trimmed
1093 .strip_prefix("type ")
1094 .unwrap()
1095 .split(|c: char| c == '=' || c.is_whitespace())
1096 .next()
1097 {
1098 defined_types.insert(name.to_string());
1099 }
1100 }
1101 if trimmed.starts_with("interface ") {
1102 if let Some(name) = trimmed
1103 .strip_prefix("interface ")
1104 .unwrap()
1105 .split(|c: char| !c.is_alphanumeric() && c != '_')
1106 .next()
1107 {
1108 defined_types.insert(name.to_string());
1109 }
1110 }
1111 }
1112
1113 let interface_section = JSEDITORAPI_TS_EDITOR_API;
1116 let mut undefined_refs = Vec::new();
1117
1118 for line in interface_section.lines() {
1119 let trimmed = line.trim();
1120
1121 if trimmed.starts_with('*')
1123 || trimmed.starts_with("/*")
1124 || trimmed.starts_with("//")
1125 || trimmed.is_empty()
1126 || trimmed == "{"
1127 || trimmed == "}"
1128 {
1129 continue;
1130 }
1131
1132 for word in trimmed.split(|c: char| !c.is_alphanumeric() && c != '_') {
1134 if word.is_empty() {
1135 continue;
1136 }
1137 if word.chars().next().is_some_and(|c| c.is_uppercase())
1139 && !defined_types.contains(word)
1140 {
1141 undefined_refs.push(word.to_string());
1142 }
1143 }
1144 }
1145
1146 undefined_refs.sort();
1148 undefined_refs.dedup();
1149
1150 assert!(
1151 undefined_refs.is_empty(),
1152 "Found undefined type references in EditorAPI interface: {:?}",
1153 undefined_refs
1154 );
1155 }
1156
1157 #[test]
1158 fn test_editor_api_cursor_methods_have_typed_returns() {
1159 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1160
1161 let api = JSEDITORAPI_TS_EDITOR_API;
1162
1163 assert!(
1165 api.contains("getPrimaryCursor(): CursorInfo | null;"),
1166 "getPrimaryCursor should return CursorInfo | null, got: {}",
1167 api.lines()
1168 .find(|l| l.contains("getPrimaryCursor"))
1169 .unwrap_or("not found")
1170 );
1171
1172 assert!(
1174 api.contains("getAllCursors(): CursorInfo[];"),
1175 "getAllCursors should return CursorInfo[], got: {}",
1176 api.lines()
1177 .find(|l| l.contains("getAllCursors"))
1178 .unwrap_or("not found")
1179 );
1180
1181 assert!(
1183 api.contains("getAllCursorPositions(): number[];"),
1184 "getAllCursorPositions should return number[], got: {}",
1185 api.lines()
1186 .find(|l| l.contains("getAllCursorPositions"))
1187 .unwrap_or("not found")
1188 );
1189 }
1190
1191 #[test]
1192 fn test_editor_api_terminal_methods_use_defined_types() {
1193 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1194
1195 let api = JSEDITORAPI_TS_EDITOR_API;
1196
1197 assert!(
1199 api.contains("CreateTerminalOptions"),
1200 "createTerminal should reference CreateTerminalOptions"
1201 );
1202 assert!(
1203 api.contains("TerminalResult"),
1204 "createTerminal should reference TerminalResult"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_editor_api_composite_methods_use_ts_prefix_types() {
1210 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1211
1212 let api = JSEDITORAPI_TS_EDITOR_API;
1213
1214 assert!(
1216 api.contains("TsCompositeHunk[]"),
1217 "updateCompositeAlignment should use TsCompositeHunk[], not CompositeHunk[]"
1218 );
1219
1220 assert!(
1222 api.contains("TsCreateCompositeBufferOptions"),
1223 "createCompositeBuffer should use TsCreateCompositeBufferOptions"
1224 );
1225 }
1226
1227 #[test]
1228 fn test_editor_api_prompt_suggestions_use_prompt_suggestion() {
1229 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1230
1231 let api = JSEDITORAPI_TS_EDITOR_API;
1232
1233 assert!(
1235 api.contains("PromptSuggestion[]"),
1236 "setPromptSuggestions should use PromptSuggestion[], not Suggestion[]"
1237 );
1238 }
1239
1240 #[test]
1241 fn test_all_editor_api_methods_present() {
1242 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1243
1244 let api = JSEDITORAPI_TS_EDITOR_API;
1245
1246 let expected_methods = vec![
1248 "apiVersion",
1249 "getActiveBufferId",
1250 "getActiveSplitId",
1251 "listBuffers",
1252 "debug",
1253 "info",
1254 "warn",
1255 "error",
1256 "setStatus",
1257 "copyToClipboard",
1258 "setClipboard",
1259 "registerCommand",
1260 "unregisterCommand",
1261 "setContext",
1262 "executeAction",
1263 "cancelPrompt",
1264 "getCursorPosition",
1265 "getBufferPath",
1266 "getBufferLength",
1267 "isBufferModified",
1268 "saveBufferToPath",
1269 "getBufferInfo",
1270 "getPrimaryCursor",
1271 "getAllCursors",
1272 "getAllCursorPositions",
1273 "getViewport",
1274 "getScreenSize",
1275 "getCursorLine",
1276 "getLineStartPosition",
1277 "getLineEndPosition",
1278 "getBufferLineCount",
1279 "scrollToLineCenter",
1280 "findBufferByPath",
1281 "getBufferSavedDiff",
1282 "insertText",
1283 "deleteRange",
1284 "insertAtCursor",
1285 "openFile",
1286 "openFileInSplit",
1287 "showBuffer",
1288 "closeBuffer",
1289 "animateArea",
1290 "animateVirtualBuffer",
1291 "cancelAnimation",
1292 "on",
1293 "off",
1294 "getEnv",
1295 "getCwd",
1296 "pathJoin",
1297 "pathDirname",
1298 "pathBasename",
1299 "pathExtname",
1300 "pathIsAbsolute",
1301 "utf8ByteLength",
1302 "fileExists",
1303 "readFile",
1304 "writeFile",
1305 "readDir",
1306 "createDir",
1307 "removePath",
1308 "renamePath",
1309 "copyPath",
1310 "getTempDir",
1311 "getConfig",
1312 "getUserConfig",
1313 "getPluginConfig",
1314 "defineConfigBoolean",
1315 "defineConfigInteger",
1316 "defineConfigNumber",
1317 "defineConfigString",
1318 "defineConfigStringArray",
1322 "reloadConfig",
1323 "reloadThemes",
1324 "reloadAndApplyTheme",
1325 "registerGrammar",
1326 "registerLanguageConfig",
1327 "registerLspServer",
1328 "reloadGrammars",
1329 "getConfigDir",
1330 "getDataDir",
1331 "getWorkingDataDir",
1332 "getTerminalDir",
1333 "getThemesDir",
1334 "applyTheme",
1335 "getThemeSchema",
1336 "getBuiltinThemes",
1337 "getAllThemes",
1338 "getThemeData",
1339 "saveThemeFile",
1340 "themeFileExists",
1341 "deleteTheme",
1342 "fileStat",
1343 "isProcessRunning",
1344 "killProcess",
1345 "pluginTranslate",
1346 "createCompositeBuffer",
1347 "updateCompositeAlignment",
1348 "closeCompositeBuffer",
1349 "flushLayout",
1350 "compositeNextHunk",
1351 "compositePrevHunk",
1352 "getHighlights",
1353 "addOverlay",
1354 "clearNamespace",
1355 "clearAllOverlays",
1356 "clearOverlaysInRange",
1357 "clearOverlaysInRangeForNamespace",
1358 "removeOverlay",
1359 "addConceal",
1360 "clearConcealNamespace",
1361 "clearConcealsInRange",
1362 "charWidth",
1363 "stringWidth",
1364 "clearConcealsInRangeForNamespace",
1365 "addSoftBreak",
1366 "clearSoftBreakNamespace",
1367 "clearSoftBreaksInRange",
1368 "submitViewTransform",
1369 "clearViewTransform",
1370 "setLayoutHints",
1371 "setFileExplorerDecorations",
1372 "clearFileExplorerDecorations",
1373 "setFileExplorerSlots",
1374 "clearFileExplorerSlots",
1375 "addVirtualText",
1376 "removeVirtualText",
1377 "removeVirtualTextsByPrefix",
1378 "clearVirtualTexts",
1379 "clearVirtualTextNamespace",
1380 "addVirtualLine",
1381 "prompt",
1382 "startPrompt",
1383 "startPromptWithInitial",
1384 "setPromptSuggestions",
1385 "setPromptSelectedIndex",
1386 "setPromptInputSync",
1387 "defineMode",
1388 "setEditorMode",
1389 "getEditorMode",
1390 "closeSplit",
1391 "setSplitBuffer",
1392 "focusSplit",
1393 "setSplitScroll",
1394 "setSplitRatio",
1395 "setSplitLabel",
1396 "clearSplitLabel",
1397 "getSplitByLabel",
1398 "distributeSplitsEvenly",
1399 "setBufferCursor",
1400 "setLineIndicator",
1401 "clearLineIndicators",
1402 "setLineNumbers",
1403 "setViewMode",
1404 "setViewState",
1405 "getViewState",
1406 "setGlobalState",
1407 "getGlobalState",
1408 "setLineWrap",
1409 "createScrollSyncGroup",
1410 "setScrollSyncAnchors",
1411 "removeScrollSyncGroup",
1412 "executeActions",
1413 "showActionPopup",
1414 "setLspMenuContributions",
1415 "disableLspForLanguage",
1416 "setLspRootUri",
1417 "getAllDiagnostics",
1418 "getHandlers",
1419 "createVirtualBuffer",
1420 "createVirtualBufferInSplit",
1421 "createVirtualBufferInExistingSplit",
1422 "setVirtualBufferContent",
1423 "getTextPropertiesAtCursor",
1424 "spawnProcess",
1425 "spawnProcessWait",
1426 "spawnHostProcess",
1427 "httpFetch",
1428 "setAuthority",
1429 "clearAuthority",
1430 "setRemoteIndicatorState",
1431 "clearRemoteIndicatorState",
1432 "getBufferText",
1433 "delay",
1434 "sendLspRequest",
1435 "spawnBackgroundProcess",
1436 "killBackgroundProcess",
1437 "createTerminal",
1438 "sendTerminalInput",
1439 "closeTerminal",
1440 "signalWindow",
1441 "refreshLines",
1442 "getCurrentLocale",
1443 "loadPlugin",
1444 "unloadPlugin",
1445 "reloadPlugin",
1446 "listPlugins",
1447 "mountFloatingWidget",
1448 "updateFloatingWidget",
1449 "unmountFloatingWidget",
1450 "floatingPanelControl",
1451 "setActiveWindowAnimated",
1452 ];
1453
1454 let mut missing = Vec::new();
1455 for method in &expected_methods {
1456 let pattern = format!("{}(", method);
1458 if !api.contains(&pattern) {
1459 missing.push(*method);
1460 }
1461 }
1462
1463 assert!(
1464 missing.is_empty(),
1465 "Missing methods in EditorAPI interface: {:?}",
1466 missing
1467 );
1468 }
1469}