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