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_server_request: {
626 language: string;
627 method: string;
628 server_command: string;
629 params: string | null;
630 };
631 lsp_server_error: {
632 language: string;
633 server_command: string;
634 error_type: string;
635 message: string;
636 };
637 lsp_status_clicked: {
638 language: string;
639 has_error: boolean;
640 missing_servers: string[];
641 user_dismissed: boolean;
642 };
643
644 // ── UI events ────────────────────────────────────────────────────────────
645 action_popup_result: { popup_id: string; action_id: string };
646 /**
647 * User clicked a plugin-registered status-bar token. Subscribers
648 * filter by `plugin_name` + `token_name`. Use this to re-open a
649 * deferred prompt or surface the relevant settings UI for whatever
650 * the token represents (e.g. trust chip → trust-elevation popup).
651 */
652 status_bar_token_clicked: { plugin_name: string; token_name: string };
653 process_output: { process_id: number; data: string };
654 language_changed: { buffer_id: number; language: string };
655 theme_inspect_key: { theme_name: string; key: string };
656 keyboard_shortcuts: { bindings: { key: string; action: string }[] };
657
658 // ── PTY terminals (see crates/fresh-core/src/hooks.rs) ───────────────────
659 // `window_id` is the editor window owning the terminal (== session id),
660 // so a plugin can attribute output to a session: output from ANY terminal
661 // in the window counts, and it fires on every PTY read (in-place redraws
662 // and carriage-return progress bars register, not just newlines).
663 terminal_output: { terminal_id: number; window_id: number; last_line: string };
664 terminal_exit: { terminal_id: number; window_id: number; exit_code: number | null };
665
666 // ── filesystem watching (watchPath plugin API) ────────────────────────────
667 path_changed: {
668 handle: number;
669 path: string;
670 /** "modify" | "create" | "delete" | "rename" | "other" */
671 kind: string;
672 };
673
674 // ── editor sessions (Orchestrator; see orchestrator-sessions-design.md) ────────
675 window_created: { id: number; label: string; root: string };
676 window_closed: { id: number };
677 active_window_changed: { previous_id: number | null; active_id: number };
678
679 // ── widget runtime ───────────────────────────────────────────────────────
680 /**
681 * A widget mounted via `editor.mountWidgetPanel` emitted a
682 * semantic event. Fired when the host's hit-test routes a mouse
683 * click to a `Toggle` / `Button` widget node within a mounted
684 * widget panel. See `docs/internal/plugin-widget-library-design.md`.
685 *
686 * Panel ids are plugin-local: the host keys panels by
687 * (plugin, id) and delivers each event only to the plugin that
688 * owns the panel, so ids never need to be globally unique.
689 * Routing is by `panel_id` (matches the id the plugin allocated
690 * at mount time) plus `widget_key` (the stable `key` set on the
691 * widget spec node, or empty when the spec did not assign one).
692 *
693 * `event_type` and `payload` shapes:
694 * * Toggle: `event_type = "toggle"`, `payload = { checked: <new> }`.
695 * * Button: `event_type = "activate"`, `payload = {}`.
696 */
697 widget_event: {
698 panel_id: number;
699 widget_key: string;
700 event_type: string;
701 payload: Record<string, unknown>;
702 };
703}
704
705/**
706 * Typed overloads of `editor.on` / `editor.off`.
707 *
708 * When the event name is a key of `HookEventMap` the handler receives a
709 * fully-typed payload — TypeScript will flag misspelled field accesses at
710 * compile time. Unknown event names fall through to the untyped base
711 * signatures in the EditorAPI interface.
712 *
713 * Both function-value and handler-name forms are supported:
714 *
715 * ```ts
716 * editor.on("buffer_activated", (args) => { /* args.buffer_id is number *\/ });
717 * editor.on("buffer_activated", "myHandler"); // registerHandler("myHandler", fn)
718 * ```
719 */
720interface EditorAPI {
721 on<K extends keyof HookEventMap>(
722 eventName: K,
723 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
724 ): void;
725 on<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
726 off<K extends keyof HookEventMap>(
727 eventName: K,
728 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
729 ): void;
730 off<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
731 /**
732 * Create a buffer group: multiple panels appearing as one tab.
733 * This is an async runtime binding (not a direct #[qjs] method).
734 */
735 createBufferGroup(
736 name: string,
737 mode: string,
738 layout: unknown,
739 ): Promise<BufferGroupResult>;
740}
741"#;
742
743 let content = format!(
744 "{}\n{}\n{}{}",
745 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API, plugin_api_trailer
746 );
747
748 validate_typescript(&content)?;
750
751 let formatted = format_typescript(&content);
753
754 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
756 let output_path = std::path::Path::new(&manifest_dir)
757 .parent() .and_then(|p| p.parent()) .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
760 .unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
761
762 let should_write = match std::fs::read_to_string(&output_path) {
764 Ok(existing) => existing != formatted,
765 Err(_) => true,
766 };
767
768 if should_write {
769 if let Some(parent) = output_path.parent() {
770 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
771 }
772 std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
773 }
774
775 Ok(())
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781
782 #[test]
785 #[ignore]
786 fn write_fresh_dts_file() {
787 write_fresh_dts().expect("Failed to write fresh.d.ts");
789 println!("Successfully generated, validated, and formatted fresh.d.ts");
790 }
791
792 #[test]
796 #[ignore]
797 fn type_check_plugins() {
798 let tsc_check = std::process::Command::new("tsc").arg("--version").output();
800
801 match tsc_check {
802 Ok(output) if output.status.success() => {
803 println!(
804 "Found tsc: {}",
805 String::from_utf8_lossy(&output.stdout).trim()
806 );
807 }
808 _ => {
809 println!("tsc not found in PATH, skipping type check test");
810 return;
811 }
812 }
813
814 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
816 let script_path = std::path::Path::new(&manifest_dir)
817 .parent()
818 .and_then(|p| p.parent())
819 .map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
820 .expect("Failed to find check-types.sh");
821
822 println!("Running type check script: {}", script_path.display());
823
824 let output = std::process::Command::new("bash")
826 .arg(&script_path)
827 .output()
828 .expect("Failed to run check-types.sh");
829
830 let stdout = String::from_utf8_lossy(&output.stdout);
831 let stderr = String::from_utf8_lossy(&output.stderr);
832
833 println!("stdout:\n{}", stdout);
834 if !stderr.is_empty() {
835 println!("stderr:\n{}", stderr);
836 }
837
838 if stdout.contains("had type errors") || !output.status.success() {
840 panic!(
841 "TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
842 );
843 }
844
845 println!("All plugins type check successfully!");
846 }
847
848 #[test]
853 fn test_get_type_decl_returns_all_expected_types() {
854 let expected_types = vec![
855 "BufferInfo",
856 "WindowInfo",
857 "CursorInfo",
858 "ViewportInfo",
859 "ScreenSize",
860 "KeyEventPayload",
861 "SplitSnapshot",
862 "ActionSpec",
863 "BufferSavedDiff",
864 "LayoutHints",
865 "SpawnResult",
866 "BackgroundProcessResult",
867 "TerminalResult",
868 "CreateTerminalOptions",
869 "CreateWindowWithTerminalOptions",
870 "SessionWithTerminalResult",
871 "TsCompositeLayoutConfig",
872 "TsCompositeSourceConfig",
873 "TsCompositePaneStyle",
874 "TsCompositeHunk",
875 "TsCreateCompositeBufferOptions",
876 "ViewTokenWireKind",
877 "TokenColor",
878 "ViewTokenStyle",
879 "ViewTokenWire",
880 "TsActionPopupAction",
881 "ActionPopupOptions",
882 "TsHighlightSpan",
883 "FileExplorerDecoration",
884 "TextPropertyEntry",
885 "CreateVirtualBufferOptions",
886 "CreateVirtualBufferInSplitOptions",
887 "CreateVirtualBufferInExistingSplitOptions",
888 "TextPropertiesAtCursor",
889 "VirtualBufferResult",
890 "PromptSuggestion",
891 "DirEntry",
892 "JsDiagnostic",
893 "JsRange",
894 "JsPosition",
895 "LanguagePackConfig",
896 "LspServerPackConfig",
897 "ProcessLimitsPackConfig",
898 "FormatterPackConfig",
899 ];
900
901 for type_name in &expected_types {
902 assert!(
903 get_type_decl(type_name).is_some(),
904 "get_type_decl should return a declaration for '{}'",
905 type_name
906 );
907 }
908 }
909
910 #[test]
911 fn test_get_type_decl_aliases_resolve_same() {
912 let alias_pairs = vec![
914 ("CompositeHunk", "TsCompositeHunk"),
915 ("CompositeLayoutConfig", "TsCompositeLayoutConfig"),
916 ("CompositeSourceConfig", "TsCompositeSourceConfig"),
917 ("CompositePaneStyle", "TsCompositePaneStyle"),
918 (
919 "CreateCompositeBufferOptions",
920 "TsCreateCompositeBufferOptions",
921 ),
922 ("ActionPopupAction", "TsActionPopupAction"),
923 ("Suggestion", "PromptSuggestion"),
924 ("JsTextPropertyEntry", "TextPropertyEntry"),
925 ];
926
927 for (rust_name, ts_name) in &alias_pairs {
928 let rust_decl = get_type_decl(rust_name);
929 let ts_decl = get_type_decl(ts_name);
930 assert!(
931 rust_decl.is_some(),
932 "get_type_decl should handle Rust name '{}'",
933 rust_name
934 );
935 assert_eq!(
936 rust_decl, ts_decl,
937 "Alias '{}' and '{}' should produce identical declarations",
938 rust_name, ts_name
939 );
940 }
941 }
942
943 #[test]
944 fn test_terminal_types_exist() {
945 let terminal_result = get_type_decl("TerminalResult");
946 assert!(
947 terminal_result.is_some(),
948 "TerminalResult should be defined"
949 );
950 let decl = terminal_result.unwrap();
951 assert!(
952 decl.contains("bufferId"),
953 "TerminalResult should have bufferId field"
954 );
955 assert!(
956 decl.contains("terminalId"),
957 "TerminalResult should have terminalId field"
958 );
959 assert!(
960 decl.contains("splitId"),
961 "TerminalResult should have splitId field"
962 );
963
964 let terminal_opts = get_type_decl("CreateTerminalOptions");
965 assert!(
966 terminal_opts.is_some(),
967 "CreateTerminalOptions should be defined"
968 );
969 }
970
971 #[test]
972 fn test_cursor_info_type_exists() {
973 let cursor_info = get_type_decl("CursorInfo");
974 assert!(cursor_info.is_some(), "CursorInfo should be defined");
975 let decl = cursor_info.unwrap();
976 assert!(
977 decl.contains("position"),
978 "CursorInfo should have position field"
979 );
980 assert!(
981 decl.contains("selection"),
982 "CursorInfo should have selection field"
983 );
984 }
985
986 #[test]
987 fn test_collect_ts_types_no_duplicates() {
988 let output = collect_ts_types();
989 let lines: Vec<&str> = output.lines().collect();
990
991 let mut declarations = std::collections::HashSet::new();
993 for line in &lines {
994 let trimmed = line.trim();
995 if trimmed.starts_with("type ") && trimmed.contains('=') {
997 let name = trimmed
998 .strip_prefix("type ")
999 .unwrap()
1000 .split(|c: char| c == '=' || c.is_whitespace())
1001 .next()
1002 .unwrap();
1003 assert!(
1004 declarations.insert(name.to_string()),
1005 "Duplicate type declaration found: '{}'",
1006 name
1007 );
1008 }
1009 }
1010 }
1011
1012 #[test]
1013 fn test_collect_ts_types_includes_dependency_types() {
1014 let output = collect_ts_types();
1015 let required_types = [
1016 "TextPropertyEntry",
1017 "TsCompositeLayoutConfig",
1018 "TsCompositeSourceConfig",
1019 "TsCompositePaneStyle",
1020 "TsCompositeHunk",
1021 "TsCreateCompositeBufferOptions",
1022 "PromptSuggestion",
1023 "BufferInfo",
1024 "CursorInfo",
1025 "TerminalResult",
1026 "CreateTerminalOptions",
1027 ];
1028
1029 for type_name in &required_types {
1030 assert!(
1031 output.contains(type_name),
1032 "collect_ts_types output should contain type '{}'",
1033 type_name
1034 );
1035 }
1036 }
1037
1038 #[test]
1039 fn test_generated_dts_validates_as_typescript() {
1040 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
1041
1042 let ts_types = collect_ts_types();
1043 let content = format!(
1044 "{}\n{}\n{}",
1045 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
1046 );
1047
1048 validate_typescript(&content).expect("Generated TypeScript should be syntactically valid");
1049 }
1050
1051 #[test]
1052 fn test_generated_dts_no_undefined_type_references() {
1053 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
1054
1055 let ts_types = collect_ts_types();
1056 let content = format!(
1057 "{}\n{}\n{}",
1058 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
1059 );
1060
1061 let mut defined_types = std::collections::HashSet::new();
1063 for builtin in &[
1065 "number",
1066 "string",
1067 "boolean",
1068 "void",
1069 "unknown",
1070 "null",
1071 "undefined",
1072 "Record",
1073 "Array",
1074 "Promise",
1075 "ProcessHandle",
1076 "PromiseLike",
1077 "BufferId",
1078 "SplitId",
1079 "EditorAPI",
1080 ] {
1081 defined_types.insert(builtin.to_string());
1082 }
1083
1084 for line in content.lines() {
1086 let trimmed = line.trim();
1087 if trimmed.starts_with("type ") && trimmed.contains('=') {
1088 if let Some(name) = trimmed
1089 .strip_prefix("type ")
1090 .unwrap()
1091 .split(|c: char| c == '=' || c.is_whitespace())
1092 .next()
1093 {
1094 defined_types.insert(name.to_string());
1095 }
1096 }
1097 if trimmed.starts_with("interface ") {
1098 if let Some(name) = trimmed
1099 .strip_prefix("interface ")
1100 .unwrap()
1101 .split(|c: char| !c.is_alphanumeric() && c != '_')
1102 .next()
1103 {
1104 defined_types.insert(name.to_string());
1105 }
1106 }
1107 }
1108
1109 let interface_section = JSEDITORAPI_TS_EDITOR_API;
1112 let mut undefined_refs = Vec::new();
1113
1114 for line in interface_section.lines() {
1115 let trimmed = line.trim();
1116
1117 if trimmed.starts_with('*')
1119 || trimmed.starts_with("/*")
1120 || trimmed.starts_with("//")
1121 || trimmed.is_empty()
1122 || trimmed == "{"
1123 || trimmed == "}"
1124 {
1125 continue;
1126 }
1127
1128 for word in trimmed.split(|c: char| !c.is_alphanumeric() && c != '_') {
1130 if word.is_empty() {
1131 continue;
1132 }
1133 if word.chars().next().is_some_and(|c| c.is_uppercase())
1135 && !defined_types.contains(word)
1136 {
1137 undefined_refs.push(word.to_string());
1138 }
1139 }
1140 }
1141
1142 undefined_refs.sort();
1144 undefined_refs.dedup();
1145
1146 assert!(
1147 undefined_refs.is_empty(),
1148 "Found undefined type references in EditorAPI interface: {:?}",
1149 undefined_refs
1150 );
1151 }
1152
1153 #[test]
1154 fn test_editor_api_cursor_methods_have_typed_returns() {
1155 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1156
1157 let api = JSEDITORAPI_TS_EDITOR_API;
1158
1159 assert!(
1161 api.contains("getPrimaryCursor(): CursorInfo | null;"),
1162 "getPrimaryCursor should return CursorInfo | null, got: {}",
1163 api.lines()
1164 .find(|l| l.contains("getPrimaryCursor"))
1165 .unwrap_or("not found")
1166 );
1167
1168 assert!(
1170 api.contains("getAllCursors(): CursorInfo[];"),
1171 "getAllCursors should return CursorInfo[], got: {}",
1172 api.lines()
1173 .find(|l| l.contains("getAllCursors"))
1174 .unwrap_or("not found")
1175 );
1176
1177 assert!(
1179 api.contains("getAllCursorPositions(): number[];"),
1180 "getAllCursorPositions should return number[], got: {}",
1181 api.lines()
1182 .find(|l| l.contains("getAllCursorPositions"))
1183 .unwrap_or("not found")
1184 );
1185 }
1186
1187 #[test]
1188 fn test_editor_api_terminal_methods_use_defined_types() {
1189 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1190
1191 let api = JSEDITORAPI_TS_EDITOR_API;
1192
1193 assert!(
1195 api.contains("CreateTerminalOptions"),
1196 "createTerminal should reference CreateTerminalOptions"
1197 );
1198 assert!(
1199 api.contains("TerminalResult"),
1200 "createTerminal should reference TerminalResult"
1201 );
1202 }
1203
1204 #[test]
1205 fn test_editor_api_composite_methods_use_ts_prefix_types() {
1206 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1207
1208 let api = JSEDITORAPI_TS_EDITOR_API;
1209
1210 assert!(
1212 api.contains("TsCompositeHunk[]"),
1213 "updateCompositeAlignment should use TsCompositeHunk[], not CompositeHunk[]"
1214 );
1215
1216 assert!(
1218 api.contains("TsCreateCompositeBufferOptions"),
1219 "createCompositeBuffer should use TsCreateCompositeBufferOptions"
1220 );
1221 }
1222
1223 #[test]
1224 fn test_editor_api_prompt_suggestions_use_prompt_suggestion() {
1225 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1226
1227 let api = JSEDITORAPI_TS_EDITOR_API;
1228
1229 assert!(
1231 api.contains("PromptSuggestion[]"),
1232 "setPromptSuggestions should use PromptSuggestion[], not Suggestion[]"
1233 );
1234 }
1235
1236 #[test]
1237 fn test_all_editor_api_methods_present() {
1238 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1239
1240 let api = JSEDITORAPI_TS_EDITOR_API;
1241
1242 let expected_methods = vec![
1244 "apiVersion",
1245 "getActiveBufferId",
1246 "getActiveSplitId",
1247 "listBuffers",
1248 "debug",
1249 "info",
1250 "warn",
1251 "error",
1252 "setStatus",
1253 "copyToClipboard",
1254 "setClipboard",
1255 "registerCommand",
1256 "unregisterCommand",
1257 "setContext",
1258 "executeAction",
1259 "cancelPrompt",
1260 "getCursorPosition",
1261 "getBufferPath",
1262 "getBufferLength",
1263 "isBufferModified",
1264 "saveBufferToPath",
1265 "getBufferInfo",
1266 "getPrimaryCursor",
1267 "getAllCursors",
1268 "getAllCursorPositions",
1269 "getViewport",
1270 "getScreenSize",
1271 "getCursorLine",
1272 "getLineStartPosition",
1273 "getLineEndPosition",
1274 "getBufferLineCount",
1275 "scrollToLineCenter",
1276 "findBufferByPath",
1277 "getBufferSavedDiff",
1278 "insertText",
1279 "deleteRange",
1280 "insertAtCursor",
1281 "openFile",
1282 "openFileInSplit",
1283 "showBuffer",
1284 "closeBuffer",
1285 "animateArea",
1286 "animateVirtualBuffer",
1287 "cancelAnimation",
1288 "on",
1289 "off",
1290 "getEnv",
1291 "getCwd",
1292 "pathJoin",
1293 "pathDirname",
1294 "pathBasename",
1295 "pathExtname",
1296 "pathIsAbsolute",
1297 "utf8ByteLength",
1298 "fileExists",
1299 "readFile",
1300 "writeFile",
1301 "readDir",
1302 "createDir",
1303 "removePath",
1304 "renamePath",
1305 "copyPath",
1306 "getTempDir",
1307 "getConfig",
1308 "getUserConfig",
1309 "getPluginConfig",
1310 "defineConfigBoolean",
1311 "defineConfigInteger",
1312 "defineConfigNumber",
1313 "defineConfigString",
1314 "defineConfigStringArray",
1318 "reloadConfig",
1319 "reloadThemes",
1320 "reloadAndApplyTheme",
1321 "registerGrammar",
1322 "registerLanguageConfig",
1323 "registerLspServer",
1324 "reloadGrammars",
1325 "getConfigDir",
1326 "getDataDir",
1327 "getWorkingDataDir",
1328 "getTerminalDir",
1329 "getThemesDir",
1330 "applyTheme",
1331 "getThemeSchema",
1332 "getBuiltinThemes",
1333 "getAllThemes",
1334 "getThemeData",
1335 "saveThemeFile",
1336 "themeFileExists",
1337 "deleteTheme",
1338 "fileStat",
1339 "isProcessRunning",
1340 "killProcess",
1341 "pluginTranslate",
1342 "createCompositeBuffer",
1343 "updateCompositeAlignment",
1344 "closeCompositeBuffer",
1345 "flushLayout",
1346 "compositeNextHunk",
1347 "compositePrevHunk",
1348 "getHighlights",
1349 "addOverlay",
1350 "clearNamespace",
1351 "clearAllOverlays",
1352 "clearOverlaysInRange",
1353 "clearOverlaysInRangeForNamespace",
1354 "removeOverlay",
1355 "addConceal",
1356 "clearConcealNamespace",
1357 "clearConcealsInRange",
1358 "addSoftBreak",
1359 "clearSoftBreakNamespace",
1360 "clearSoftBreaksInRange",
1361 "submitViewTransform",
1362 "clearViewTransform",
1363 "setLayoutHints",
1364 "setFileExplorerDecorations",
1365 "clearFileExplorerDecorations",
1366 "setFileExplorerSlots",
1367 "clearFileExplorerSlots",
1368 "addVirtualText",
1369 "removeVirtualText",
1370 "removeVirtualTextsByPrefix",
1371 "clearVirtualTexts",
1372 "clearVirtualTextNamespace",
1373 "addVirtualLine",
1374 "prompt",
1375 "startPrompt",
1376 "startPromptWithInitial",
1377 "setPromptSuggestions",
1378 "setPromptSelectedIndex",
1379 "setPromptInputSync",
1380 "defineMode",
1381 "setEditorMode",
1382 "getEditorMode",
1383 "closeSplit",
1384 "setSplitBuffer",
1385 "focusSplit",
1386 "setSplitScroll",
1387 "setSplitRatio",
1388 "setSplitLabel",
1389 "clearSplitLabel",
1390 "getSplitByLabel",
1391 "distributeSplitsEvenly",
1392 "setBufferCursor",
1393 "setLineIndicator",
1394 "clearLineIndicators",
1395 "setLineNumbers",
1396 "setViewMode",
1397 "setViewState",
1398 "getViewState",
1399 "setGlobalState",
1400 "getGlobalState",
1401 "setLineWrap",
1402 "createScrollSyncGroup",
1403 "setScrollSyncAnchors",
1404 "removeScrollSyncGroup",
1405 "executeActions",
1406 "showActionPopup",
1407 "setLspMenuContributions",
1408 "disableLspForLanguage",
1409 "setLspRootUri",
1410 "getAllDiagnostics",
1411 "getHandlers",
1412 "createVirtualBuffer",
1413 "createVirtualBufferInSplit",
1414 "createVirtualBufferInExistingSplit",
1415 "setVirtualBufferContent",
1416 "getTextPropertiesAtCursor",
1417 "spawnProcess",
1418 "spawnProcessWait",
1419 "spawnHostProcess",
1420 "httpFetch",
1421 "setAuthority",
1422 "clearAuthority",
1423 "setRemoteIndicatorState",
1424 "clearRemoteIndicatorState",
1425 "getBufferText",
1426 "delay",
1427 "sendLspRequest",
1428 "spawnBackgroundProcess",
1429 "killBackgroundProcess",
1430 "createTerminal",
1431 "sendTerminalInput",
1432 "closeTerminal",
1433 "signalWindow",
1434 "refreshLines",
1435 "getCurrentLocale",
1436 "loadPlugin",
1437 "unloadPlugin",
1438 "reloadPlugin",
1439 "listPlugins",
1440 "mountFloatingWidget",
1441 "updateFloatingWidget",
1442 "unmountFloatingWidget",
1443 "floatingPanelControl",
1444 "setActiveWindowAnimated",
1445 ];
1446
1447 let mut missing = Vec::new();
1448 for method in &expected_methods {
1449 let pattern = format!("{}(", method);
1451 if !api.contains(&pattern) {
1452 missing.push(*method);
1453 }
1454 }
1455
1456 assert!(
1457 missing.is_empty(),
1458 "Missing methods in EditorAPI interface: {:?}",
1459 missing
1460 );
1461 }
1462}