1use std::path::{Path, PathBuf};
15
16const CRASH_FUSE_THRESHOLD: u32 = 3;
18const CRASH_FUSE_WINDOW_SECS: u64 = 300;
20pub const INIT_PLUGIN_NAME: &str = "init.ts";
22
23pub const STARTER_TEMPLATE: &str = r#"/// <reference path="./types/fresh.d.ts" />
31/// <reference path="./types/plugins.d.ts" />
32const editor = getEditor();
33
34// Fresh init.ts — decisions that depend on the environment at startup.
35//
36// init.ts is NOT for:
37// - Static preferences (tab size, line numbers, ...) -> Settings UI
38// - Key bindings -> Keybindings editor
39// - Themes you always want -> Theme selector
40// - Reusable features -> A plugin package
41//
42// init.ts IS for things that:
43// - Register code handlers, commands, etc.
44// - Depend on where/how Fresh is starting (host, SSH, $TERM, project, ...)
45// - Would differ across machines or launches
46// - Can't live in a shared config.json without lying to teammates
47//
48// API reference: ~/.config/fresh/types/fresh.d.ts (same as plugins)
49// Commands: Ctrl+P -> "init: Reload", "init: Check"
50// CLI: fresh --cmd init check | fresh --safe | fresh --no-init
51
52// Example: Add a command to select (mark) from current cursor to target line.
53//
54// registerHandler("select_to_line_handler", async function start_review_range() {
55// editor.executeActions([
56// { action: "set_mark", count: 1 },
57// { action: "goto_line", count: 1 },
58// ]);
59// });
60//
61// editor.registerCommand(
62// "select_to_line",
63// "Select from current position to target line",
64// "select_to_line_handler",
65// );
66//
67
68// Example: fade the editor in from black to the target theme. Uses
69// `overrideThemeColors` (in-memory, no disk I/O) for each frame, then
70// calls `applyTheme` at the end to drop the overrides and land cleanly
71// on the saved theme. `editor.delay(ms)` returns a Promise, so an async
72// for-loop is all the timing machinery we need — no setInterval.
73// (async () => {
74// const target = "one-dark";
75// const data = editor.getThemeData(target) as
76// | { editor?: Record<string, [number, number, number]> }
77// | null;
78// const bg = data?.editor?.bg ?? [30, 30, 30];
79// const fg = data?.editor?.fg ?? [220, 220, 220];
80// const frames = 18;
81// const stepMs = 16;
82// const lerp = (a: number, b: number, t: number) =>
83// Math.round(a + (b - a) * t);
84// for (let i = 1; i <= frames; i++) {
85// const t = i / frames;
86// editor.overrideThemeColors({
87// "editor.bg": [lerp(0, bg[0], t), lerp(0, bg[1], t), lerp(0, bg[2], t)],
88// "editor.fg": [lerp(0, fg[0], t), lerp(0, fg[1], t), lerp(0, fg[2], t)],
89// });
90// await editor.delay(stepMs);
91// }
92// editor.applyTheme(target); // drop overrides, settle on the real theme
93// })();
94
95// Example: calmer UI over SSH. setSetting writes to the runtime layer —
96// nothing is persisted to disk, and removing this file is a complete undo.
97// if (editor.getEnv("SSH_TTY")) {
98// editor.setSetting("editor.diagnostics_inline_text", false);
99// editor.setSetting("terminal.mouse", false);
100// }
101
102// Example: host-specific rust-analyzer path.
103// if (editor.getEnv("HOSTNAME") === "my-mac") {
104// editor.registerLspServer("rust", {
105// command: "/opt/homebrew/bin/rust-analyzer",
106// args: [],
107// autoStart: true,
108// initializationOptions: null,
109// processLimits: null,
110// });
111// }
112
113// Example: env-driven profile (fresh invoked as FRESH_PROFILE=writing fresh).
114// if (editor.getEnv("FRESH_PROFILE") === "writing") {
115// editor.setSetting("editor.line_wrap", true);
116// editor.setSetting("editor.wrap_column", 80);
117// }
118
119// Example: configure a plugin once it loads. `plugins_loaded` fires after
120// every registry plugin and init.ts top-level code has run.
121// editor.on("plugins_loaded", () => {
122// const api = editor.getPluginApi("my-plugin");
123// if (api) api.configure({ option: "value" });
124// });
125
126// Example: enable the opt-in Dashboard widgets (weather, GitHub).
127// Both hit the network on every refresh, so the plugin ships with
128// only `git` and `disk` registered by default. The handlers live
129// on the exported plugin API as `builtinHandlers` — pass them to
130// `registerSection` with whatever name you like.
131//
132// editor.on("plugins_loaded", () => {
133// const dash = editor.getPluginApi("dashboard");
134// if (!dash) return;
135// dash.registerSection("weather", dash.builtinHandlers.weather);
136// dash.registerSection("github", dash.builtinHandlers.github);
137// });
138
139// Example: disable the Dashboard's auto-open behaviour on this
140// machine (it will still be available via the "Show Dashboard"
141// command). The same toggle can also be set persistently in
142// config.json at `plugins.dashboard.auto-open`.
143//
144// editor.on("plugins_loaded", () => {
145// const dash = editor.getPluginApi("dashboard");
146// if (dash) dash.setAutoOpen(false);
147// });
148
149// Example: add a custom section to the Dashboard plugin.
150//
151// `editor.getPluginApi("dashboard")` is typed automatically via
152// `types/plugins.d.ts` — no `as` cast needed. Hover over `dash` or
153// `ctx` in your editor to see the full API.
154//
155// editor.on("plugins_loaded", () => {
156// const dash = editor.getPluginApi("dashboard");
157// if (!dash) return;
158// dash.registerSection("todo", async (ctx) => {
159// // Pretend we read a TODO count from somewhere async.
160// const count = 3;
161// if (count === 0) {
162// ctx.kv("status", "inbox zero", "ok");
163// return;
164// }
165// ctx.kv("open", String(count), count > 5 ? "warn" : "value");
166// ctx.text(" " + "see all".padEnd(10), { color: "muted" });
167// ctx.text("open inbox", {
168// color: "accent",
169// bold: true,
170// onClick: () => editor.executeAction("open_inbox"),
171// });
172// ctx.newline();
173// });
174// });
175
176// Example: register a custom Live Grep search backend.
177//
178// The bundled providers (ripgrep → git grep → grep) are picked by
179// priority on each invocation. Higher-priority registrations win;
180// register from init.ts to use a custom indexer or wrapper script.
181//
182// editor.on("plugins_loaded", () => {
183// const liveGrep = editor.getPluginApi("live-grep");
184// if (!liveGrep) return;
185// liveGrep.registerProvider({
186// name: "fff",
187// priority: 100,
188// isAvailable: async () => {
189// try {
190// const r = await editor.spawnProcess("fff", ["--version"], editor.getCwd());
191// return r.exit_code === 0;
192// } catch {
193// return false;
194// }
195// },
196// search: async (query, { cwd, maxResults }) => {
197// const r = await editor.spawnProcess("fff", [query], cwd);
198// // Return GrepMatch[]: { file, line, column, content }
199// return r.stdout.split("\n").filter(Boolean).map((line) => {
200// const [file, lineStr, ...rest] = line.split(":");
201// return {
202// file,
203// line: parseInt(lineStr, 10) || 1,
204// column: 1,
205// content: rest.join(":"),
206// };
207// }).slice(0, maxResults);
208// },
209// });
210// });
211"#;
212
213const INIT_TSCONFIG: &str = r#"{
217 "compilerOptions": {
218 "target": "ES2020",
219 "module": "ES2020",
220 "moduleResolution": "node",
221 "strict": true,
222 "noEmit": true,
223 "skipLibCheck": true,
224 "lib": ["ES2020"],
225 "types": []
226 },
227 "files": ["init.ts", "types/fresh.d.ts", "types/plugins.d.ts"]
228}
229"#;
230
231#[cfg(feature = "embed-plugins")]
236fn embedded_fresh_dts_path() -> Option<PathBuf> {
237 let embedded = crate::services::plugins::embedded::get_embedded_plugins_dir()?;
238 let p = embedded.join("lib").join("fresh.d.ts");
239 p.exists().then_some(p)
240}
241
242#[cfg(not(feature = "embed-plugins"))]
243fn embedded_fresh_dts_path() -> Option<PathBuf> {
244 None
245}
246
247pub fn refresh_types_scaffolding(config_dir: &Path) {
260 let Some(source) = embedded_fresh_dts_path() else {
261 tracing::warn!(
262 "init.ts: embedded fresh.d.ts unavailable; \
263 LSP completions in init.ts will be unavailable"
264 );
265 return;
266 };
267
268 let types_dir = config_dir.join("types");
269 if let Err(e) = std::fs::create_dir_all(&types_dir) {
270 tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
271 return;
272 }
273 let dest_dts = types_dir.join("fresh.d.ts");
274 if let Err(e) = std::fs::copy(&source, &dest_dts) {
275 tracing::warn!(
276 "init.ts: failed to copy fresh.d.ts from {} to {}: {e}",
277 source.display(),
278 dest_dts.display()
279 );
280 }
281
282 let tsconfig = config_dir.join("tsconfig.json");
283 if !tsconfig.exists() {
284 if let Err(e) = std::fs::write(&tsconfig, INIT_TSCONFIG) {
285 tracing::warn!("init.ts: failed to write {}: {e}", tsconfig.display());
286 }
287 }
288}
289
290pub fn write_plugin_declarations(config_dir: &Path, declarations: &[(String, String)]) {
299 let types_dir = config_dir.join("types");
300 if let Err(e) = std::fs::create_dir_all(&types_dir) {
301 tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
302 return;
303 }
304 let dest = types_dir.join("plugins.d.ts");
305
306 let mut sorted: Vec<&(String, String)> = declarations.iter().collect();
310 sorted.sort_by(|a, b| a.0.cmp(&b.0));
311
312 let mut body = String::new();
313 body.push_str(
314 "// AUTO-GENERATED by fresh — do not edit.\n\
315 //\n\
316 // Aggregate of every loaded plugin's isolated-declarations\n\
317 // emit (oxc). This is what makes `editor.getPluginApi(\"foo\")`\n\
318 // return a typed result in init.ts / downstream plugins —\n\
319 // each plugin that declares `FreshPluginRegistry` here\n\
320 // contributes its augmentation.\n\n",
321 );
322 for (name, dts) in sorted {
323 let trimmed = dts.trim();
324 if trimmed.is_empty() || trimmed == "export {};" {
329 continue;
330 }
331 body.push_str(&format!("// ── {name} ─────────────────────\n"));
332 body.push_str(dts.trim_end());
333 body.push_str("\n\n");
334 }
335
336 if let Err(e) = std::fs::write(&dest, &body) {
337 tracing::warn!("init.ts: failed to write {}: {e}", dest.display());
338 }
339}
340
341pub fn ensure_starter(config_dir: &Path) -> std::io::Result<PathBuf> {
347 let path = init_ts_path(config_dir);
348 if !path.exists() {
349 if let Some(parent) = path.parent() {
350 std::fs::create_dir_all(parent)?;
351 }
352 std::fs::write(&path, STARTER_TEMPLATE)?;
353 }
354 refresh_types_scaffolding(config_dir);
355 Ok(path)
356}
357
358#[derive(Debug)]
360pub enum InitOutcome {
361 NotFound,
363 Disabled,
365 CrashFused { failures: u32 },
367 Loaded,
369 Failed { message: String },
371}
372
373pub fn init_ts_path(config_dir: &Path) -> PathBuf {
375 config_dir.join("init.ts")
376}
377
378fn crashes_path(config_dir: &Path) -> PathBuf {
380 config_dir.join("logs").join("init.crashes")
381}
382
383#[derive(Debug, Default)]
384struct CrashState {
385 count: u32,
386 last_increment_epoch: u64,
387}
388
389impl CrashState {
390 fn load(config_dir: &Path) -> Self {
391 let path = crashes_path(config_dir);
392 let Ok(text) = std::fs::read_to_string(&path) else {
393 return Self::default();
394 };
395 let mut count = 0u32;
396 let mut last = 0u64;
397 for (i, line) in text.lines().enumerate() {
398 let trimmed = line.trim();
399 if trimmed.is_empty() {
400 continue;
401 }
402 match i {
403 0 => count = trimmed.parse().unwrap_or(0),
404 1 => last = trimmed.parse().unwrap_or(0),
405 _ => break,
406 }
407 }
408 Self {
409 count,
410 last_increment_epoch: last,
411 }
412 }
413
414 fn save(&self, config_dir: &Path) -> std::io::Result<()> {
415 let path = crashes_path(config_dir);
416 if let Some(parent) = path.parent() {
417 std::fs::create_dir_all(parent)?;
418 }
419 std::fs::write(
420 &path,
421 format!("{}\n{}\n", self.count, self.last_increment_epoch),
422 )
423 }
424
425 fn clear(config_dir: &Path) {
426 let path = crashes_path(config_dir);
427 if let Err(e) = std::fs::remove_file(&path) {
428 if e.kind() != std::io::ErrorKind::NotFound {
429 tracing::debug!(
430 "init.ts crash-fuse: failed to clear {}: {e}",
431 path.display()
432 );
433 }
434 }
435 }
436}
437
438fn now_epoch_secs() -> u64 {
439 std::time::SystemTime::now()
440 .duration_since(std::time::UNIX_EPOCH)
441 .map(|d| d.as_secs())
442 .unwrap_or(0)
443}
444
445fn check_and_increment_fuse(config_dir: &Path) -> Option<u32> {
451 let now = now_epoch_secs();
452 let mut state = CrashState::load(config_dir);
453
454 if state.last_increment_epoch == 0
456 || now.saturating_sub(state.last_increment_epoch) > CRASH_FUSE_WINDOW_SECS
457 {
458 state.count = 0;
459 }
460
461 if state.count >= CRASH_FUSE_THRESHOLD {
462 return Some(state.count);
463 }
464
465 state.count += 1;
466 state.last_increment_epoch = now;
467 if let Err(e) = state.save(config_dir) {
468 tracing::debug!("init.ts crash-fuse: failed to persist counter: {e}");
469 }
470
471 None
472}
473
474pub fn record_success(config_dir: &Path) {
477 CrashState::clear(config_dir);
478}
479
480pub fn read_init_script(config_dir: &Path) -> std::io::Result<Option<String>> {
483 let path = init_ts_path(config_dir);
484 match std::fs::read_to_string(&path) {
485 Ok(s) => Ok(Some(s)),
486 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
487 Err(e) => Err(e),
488 }
489}
490
491pub fn should_skip(enabled: bool) -> bool {
494 !enabled
495}
496
497pub fn describe(outcome: &InitOutcome) -> String {
499 match outcome {
500 InitOutcome::NotFound => String::from("init.ts: not present"),
501 InitOutcome::Disabled => String::from("init.ts: skipped (--no-init / --safe)"),
502 InitOutcome::CrashFused { failures } => format!(
503 "init.ts: skipped after {failures} consecutive failures — fix ~/.config/fresh/init.ts or remove it"
504 ),
505 InitOutcome::Loaded => String::from("init.ts: loaded"),
506 InitOutcome::Failed { message } => format!("init.ts: {message}"),
507 }
508}
509
510pub enum LoadDecision {
513 Skip(InitOutcome),
514 Load { source: String },
515}
516
517pub fn decide_load(config_dir: &Path, enabled: bool) -> LoadDecision {
518 if should_skip(enabled) {
519 return LoadDecision::Skip(InitOutcome::Disabled);
520 }
521 match read_init_script(config_dir) {
522 Ok(None) => LoadDecision::Skip(InitOutcome::NotFound),
523 Err(e) => LoadDecision::Skip(InitOutcome::Failed {
524 message: format!("read failed: {e}"),
525 }),
526 Ok(Some(source)) => {
527 if let Some(failures) = check_and_increment_fuse(config_dir) {
528 LoadDecision::Skip(InitOutcome::CrashFused { failures })
529 } else {
530 LoadDecision::Load { source }
531 }
532 }
533 }
534}
535
536#[derive(Debug)]
538pub struct CheckReport {
539 pub ok: bool,
540 pub diagnostics: Vec<CheckDiagnostic>,
541 pub path: PathBuf,
542}
543
544#[derive(Debug)]
545pub struct CheckDiagnostic {
546 pub severity: CheckSeverity,
547 pub message: String,
548 pub line: u32,
550 pub column: u32,
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub enum CheckSeverity {
555 Error,
556 Warning,
557}
558
559pub fn check(config_dir: &Path) -> CheckReport {
568 use oxc_allocator::Allocator;
569 use oxc_parser::Parser;
570 use oxc_span::SourceType;
571
572 let path = init_ts_path(config_dir);
573
574 let source = match std::fs::read_to_string(&path) {
575 Ok(s) => s,
576 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
577 return CheckReport {
578 ok: true,
579 diagnostics: Vec::new(),
580 path,
581 };
582 }
583 Err(e) => {
584 return CheckReport {
585 ok: false,
586 diagnostics: vec![CheckDiagnostic {
587 severity: CheckSeverity::Error,
588 message: format!("read failed: {e}"),
589 line: 0,
590 column: 0,
591 }],
592 path,
593 };
594 }
595 };
596
597 let allocator = Allocator::default();
598 let source_type = SourceType::from_path(&path).unwrap_or_default();
599 let parser_ret = Parser::new(&allocator, &source, source_type).parse();
600
601 let mut diagnostics = Vec::new();
602 for err in &parser_ret.errors {
603 let (line, column) = err
607 .labels
608 .as_ref()
609 .and_then(|v| v.first())
610 .map(|l| line_col(&source, l.offset()))
611 .unwrap_or((0, 0));
612 diagnostics.push(CheckDiagnostic {
613 severity: CheckSeverity::Error,
614 message: err.message.to_string(),
615 line,
616 column,
617 });
618 }
619
620 CheckReport {
621 ok: parser_ret.errors.is_empty(),
622 diagnostics,
623 path,
624 }
625}
626
627fn line_col(source: &str, offset: usize) -> (u32, u32) {
629 let clipped = source.get(..offset).unwrap_or(source);
630 let line = 1 + clipped.bytes().filter(|&b| b == b'\n').count();
631 let col = 1 + clipped
632 .rsplit('\n')
633 .next()
634 .map(|s| s.chars().count())
635 .unwrap_or(0);
636 (line as u32, col as u32)
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use tempfile::TempDir;
643
644 #[test]
645 fn init_ts_path_is_under_config_dir() {
646 let p = init_ts_path(Path::new("/tmp/fresh"));
647 assert_eq!(p, PathBuf::from("/tmp/fresh/init.ts"));
648 }
649
650 #[test]
651 fn crash_fuse_trips_after_threshold_consecutive_failures() {
652 let tmp = TempDir::new().unwrap();
653 let dir = tmp.path();
654
655 for _ in 0..CRASH_FUSE_THRESHOLD {
658 assert!(check_and_increment_fuse(dir).is_none());
659 }
660
661 let tripped = check_and_increment_fuse(dir);
663 assert!(tripped.is_some());
664 assert_eq!(tripped.unwrap(), CRASH_FUSE_THRESHOLD);
665 }
666
667 #[test]
668 fn record_success_resets_the_fuse() {
669 let tmp = TempDir::new().unwrap();
670 let dir = tmp.path();
671
672 for _ in 0..CRASH_FUSE_THRESHOLD {
673 check_and_increment_fuse(dir);
674 }
675 record_success(dir);
676
677 assert!(check_and_increment_fuse(dir).is_none());
679 }
680
681 #[test]
682 fn stale_failures_outside_window_are_ignored() {
683 let tmp = TempDir::new().unwrap();
684 let dir = tmp.path();
685
686 let state = CrashState {
688 count: CRASH_FUSE_THRESHOLD + 5,
689 last_increment_epoch: now_epoch_secs().saturating_sub(CRASH_FUSE_WINDOW_SECS + 1),
690 };
691 state.save(dir).unwrap();
692
693 assert!(check_and_increment_fuse(dir).is_none());
695 }
696
697 #[test]
698 fn decide_load_reports_not_found_when_missing() {
699 let tmp = TempDir::new().unwrap();
700 match decide_load(tmp.path(), true) {
701 LoadDecision::Skip(InitOutcome::NotFound) => {}
702 other => panic!("expected NotFound, got {other:?}"),
703 }
704 }
705
706 #[test]
707 fn decide_load_reports_disabled_when_flag_says_so() {
708 let tmp = TempDir::new().unwrap();
709 std::fs::write(init_ts_path(tmp.path()), "// hi").unwrap();
710 match decide_load(tmp.path(), false) {
711 LoadDecision::Skip(InitOutcome::Disabled) => {}
712 other => panic!("expected Disabled, got {other:?}"),
713 }
714 }
715
716 #[test]
717 fn decide_load_returns_source_when_file_present_and_enabled() {
718 let tmp = TempDir::new().unwrap();
719 std::fs::write(init_ts_path(tmp.path()), "const x = 1;").unwrap();
720 match decide_load(tmp.path(), true) {
721 LoadDecision::Load { source } => assert_eq!(source, "const x = 1;"),
722 other => panic!("expected Load, got {other:?}"),
723 }
724 }
725
726 impl std::fmt::Debug for LoadDecision {
728 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
729 match self {
730 LoadDecision::Skip(o) => write!(f, "Skip({o:?})"),
731 LoadDecision::Load { source } => write!(f, "Load({} chars)", source.len()),
732 }
733 }
734 }
735
736 #[test]
737 fn check_no_file_is_ok() {
738 let tmp = TempDir::new().unwrap();
739 let report = check(tmp.path());
740 assert!(report.ok);
741 assert!(report.diagnostics.is_empty());
742 }
743
744 #[test]
745 fn check_clean_source_is_ok() {
746 let tmp = TempDir::new().unwrap();
747 std::fs::write(
748 init_ts_path(tmp.path()),
749 "const editor = getEditor();\neditor.setStatus('hi');\n",
750 )
751 .unwrap();
752 let report = check(tmp.path());
753 assert!(report.ok, "diagnostics: {:?}", report.diagnostics);
754 }
755
756 #[test]
757 fn check_syntax_error_reports_a_diagnostic() {
758 let tmp = TempDir::new().unwrap();
759 std::fs::write(init_ts_path(tmp.path()), "function broken(\n").unwrap();
761 let report = check(tmp.path());
762 assert!(!report.ok);
763 assert!(!report.diagnostics.is_empty());
764 assert_eq!(report.diagnostics[0].severity, CheckSeverity::Error);
765 }
766
767 #[test]
768 fn starter_template_references_both_dts_files() {
769 assert!(
770 STARTER_TEMPLATE.contains(r#"/// <reference path="./types/fresh.d.ts" />"#),
771 "starter template must reference fresh.d.ts"
772 );
773 assert!(
774 STARTER_TEMPLATE.contains(r#"/// <reference path="./types/plugins.d.ts" />"#),
775 "starter template must reference plugins.d.ts so plugin APIs are typed"
776 );
777 }
778
779 #[test]
780 fn write_plugin_declarations_skips_empty_export_plugins() {
781 let tmp = TempDir::new().unwrap();
782 let decls = vec![
783 ("noop".to_string(), "export {};\n".to_string()),
784 ("blank".to_string(), "".to_string()),
785 (
786 "dashboard".to_string(),
787 "export type DashboardApi = { foo(): void; };\n\
788 declare global { interface FreshPluginRegistry { dashboard: DashboardApi; } }\n\
789 export {};\n"
790 .to_string(),
791 ),
792 ];
793 write_plugin_declarations(tmp.path(), &decls);
794 let body = std::fs::read_to_string(tmp.path().join("types/plugins.d.ts")).unwrap();
795 assert!(
796 body.contains("// ── dashboard ─"),
797 "dashboard section missing: {body}"
798 );
799 assert!(
800 body.contains("DashboardApi"),
801 "dashboard API missing: {body}"
802 );
803 assert!(
804 !body.contains("// ── noop ─"),
805 "empty-export plugin should not get a section header: {body}"
806 );
807 assert!(
808 !body.contains("// ── blank ─"),
809 "blank-emit plugin should not get a section header: {body}"
810 );
811 }
812}