diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b157618e7..a47b77683 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,4 +102,4 @@ jobs: if: hashFiles('Cargo.lock') == '' run: cargo generate-lockfile - name: Run tests - run: cargo nextest run -j=4 --no-fail-fast --locked --all-features --all-targets + run: cargo nextest run -j=16 --no-fail-fast --locked --all-features --all-targets diff --git a/crates/fresh-editor/plugins/lib/fresh.d.ts b/crates/fresh-editor/plugins/lib/fresh.d.ts index 3cd09a2ce..7fd3ab74e 100644 --- a/crates/fresh-editor/plugins/lib/fresh.d.ts +++ b/crates/fresh-editor/plugins/lib/fresh.d.ts @@ -884,6 +884,11 @@ interface EditorAPI { */ getThemesDir(): string; /** + * Get the directory containing the currently executing plugin + * Returns empty string if plugin path is not available + */ + getPluginDir(): string; + /** * Apply a theme by name */ applyTheme(themeName: string): boolean; diff --git a/crates/fresh-editor/plugins/pkg.ts b/crates/fresh-editor/plugins/pkg.ts index 272b98f81..5f5714626 100644 --- a/crates/fresh-editor/plugins/pkg.ts +++ b/crates/fresh-editor/plugins/pkg.ts @@ -551,15 +551,28 @@ function getInstalledPackages(type: "plugin" | "theme" | "language" | "bundle"): const manifestPath = editor.pathJoin(pkgPath, "package.json"); const manifest = readJsonFile(manifestPath); - // Try to get git remote - const gitConfigPath = editor.pathJoin(pkgPath, ".git", "config"); + // Try to get source - check both git remote and .fresh-source.json let source = ""; - if (editor.fileExists(gitConfigPath)) { - const gitConfig = editor.readFile(gitConfigPath); - if (gitConfig) { - const match = gitConfig.match(/url\s*=\s*(.+)/); - if (match) { - source = match[1].trim(); + + // First try .fresh-source.json (for local path installations) + const freshSourcePath = editor.pathJoin(pkgPath, ".fresh-source.json"); + if (editor.fileExists(freshSourcePath)) { + const sourceInfo = readJsonFile<{local_path?: string, original_url?: string}>(freshSourcePath); + if (sourceInfo?.original_url) { + source = sourceInfo.original_url; + } + } + + // Fall back to git remote if no .fresh-source.json + if (!source) { + const gitConfigPath = editor.pathJoin(pkgPath, ".git", "config"); + if (editor.fileExists(gitConfigPath)) { + const gitConfig = editor.readFile(gitConfigPath); + if (gitConfig) { + const match = gitConfig.match(/url\s*=\s*(.+)/); + if (match) { + source = match[1].trim(); + } } } } @@ -570,7 +583,7 @@ function getInstalledPackages(type: "plugin" | "theme" | "language" | "bundle"): type, source, version: manifest?.version || "unknown", - manifest + manifest: manifest || undefined }); } } @@ -1351,7 +1364,20 @@ async function updatePackage(pkg: InstalledPackage): Promise { /** * Remove a package */ -async function removePackage(pkg: InstalledPackage): Promise { +async function removePackage(pkg: InstalledPackage, skipConfirmation = false): Promise { + // Confirm with user before uninstalling (unless called from reinstall) + if (!skipConfirmation) { + const response = await editor.prompt( + `Uninstall ${pkg.name}? (yes/no) `, + "no" + ); + + if (response?.toLowerCase() !== "yes") { + editor.setStatus("Uninstall cancelled"); + return false; + } + } + editor.setStatus(`Removing ${pkg.name}...`); // Unload the plugin first (ignore errors - plugin might not be loaded) @@ -1383,6 +1409,65 @@ async function removePackage(pkg: InstalledPackage): Promise { } } +/** + * Reinstall a package from its source (uninstall + install latest) + * This performs a clean reinstall to get the latest version from the git repository. + */ +async function reinstallPackage(pkg: InstalledPackage): Promise { + if (!pkg.source) { + editor.setStatus(`Cannot reinstall ${pkg.name}: no source URL found`); + editor.warn(`[pkg] Cannot reinstall ${pkg.name}: no source URL found`); + return false; + } + + // Confirm with user before reinstalling + const response = await editor.prompt( + `Reinstall ${pkg.name} from ${pkg.source}? (yes/no) `, + "no" + ); + + if (response?.toLowerCase() !== "yes") { + editor.setStatus("Reinstall cancelled"); + return false; + } + + editor.setStatus(`Reinstalling ${pkg.name}...`); + editor.debug(`[pkg] Reinstalling ${pkg.name} from ${pkg.source}`); + + // Step 1: Remove the package (skip confirmation since reinstall already confirmed) + const removed = await removePackage(pkg, true); + if (!removed) { + const msg = `Failed to reinstall ${pkg.name}: could not uninstall`; + editor.setStatus(msg); + editor.warn(`[pkg] ${msg}`); + return false; + } + + // Step 2: Reinstall from source using installPackage which handles both local and remote sources + editor.setStatus(`Installing ${pkg.name} from ${pkg.source}...`); + editor.debug(`[pkg] Installing from source: ${pkg.source}, type: ${pkg.type}`); + + try { + const result = await installPackage(pkg.source, pkg.name, pkg.type); + + if (result) { + editor.setStatus(`Reinstalled ${pkg.name} successfully`); + editor.debug(`[pkg] Reinstalled ${pkg.name} successfully`); + return true; + } else { + const msg = `Failed to reinstall ${pkg.name} from ${pkg.source}`; + editor.setStatus(msg); + editor.warn(`[pkg] ${msg}`); + return false; + } + } catch (e) { + const msg = `Failed to reinstall ${pkg.name}: ${e}`; + editor.setStatus(msg); + editor.warn(`[pkg] ${msg}`); + return false; + } +} + /** * Update all packages */ @@ -1519,6 +1604,7 @@ interface PackageListItem { author?: string; license?: string; repository?: string; + source?: string; stars?: number; downloads?: number; keywords?: string[]; @@ -1851,7 +1937,18 @@ function getActionButtons(): string[] { const item = items[pkgState.selectedIndex]; if (item.installed) { - return item.updateAvailable ? ["Update", "Uninstall"] : ["Uninstall"]; + // Check if package has a source URL for reinstall + const hasSource = item.installedPackage?.source; + + if (item.updateAvailable && hasSource) { + return ["Update", "Reinstall", "Uninstall"]; + } else if (item.updateAvailable) { + return ["Update", "Uninstall"]; + } else if (hasSource) { + return ["Reinstall", "Uninstall"]; + } else { + return ["Uninstall"]; + } } else { return ["Install"]; } @@ -2360,6 +2457,9 @@ async function openPackageManager(): Promise { pkgState.bufferId = result.bufferId; pkgState.isOpen = true; + // Ensure pkg-manager mode is active for keybindings + editor.setEditorMode("pkg-manager"); + // Apply initial highlighting applyPkgManagerHighlighting(); @@ -2389,6 +2489,9 @@ function closePackageManager(): void { editor.showBuffer(pkgState.sourceBufferId); } + // Reset editor mode to normal + editor.setEditorMode("normal"); + // Reset state pkgState.isOpen = false; pkgState.bufferId = null; @@ -2465,18 +2568,80 @@ globalThis.pkg_nav_down = function(): void { }; globalThis.pkg_next_button = function(): void { + editor.debug("[pkg] pkg_next_button called, isOpen=" + pkgState.isOpen); if (!pkgState.isOpen) return; + const actions = getActionButtons(); + const hasActions = actions.length > 0; + editor.debug("[pkg] hasActions=" + hasActions + ", focus.type=" + pkgState.focus.type); + + // Special handling: from list with actions, go to first action button + if (hasActions && pkgState.focus.type === "list") { + pkgState.focus = { type: "action", index: 0 }; + updatePkgManagerView(); + return; + } + + // From action button, go to next action button or continue to next element + if (hasActions && pkgState.focus.type === "action") { + const nextActionIdx = pkgState.focus.index + 1; + if (nextActionIdx < actions.length) { + // Go to next action button + pkgState.focus = { type: "action", index: nextActionIdx }; + updatePkgManagerView(); + return; + } + // After last action button, fall through to continue to next UI element + } + + // Default behavior: cycle through all elements, but wrap to list if actions available const order = getFocusOrder(); const currentIdx = getCurrentFocusIndex(); - const nextIdx = (currentIdx + 1) % order.length; - pkgState.focus = order[nextIdx]; + let nextIdx = (currentIdx + 1) % order.length; + + // If we wrapped around and action buttons are available, go to list instead of first element + if (hasActions && nextIdx === 0) { + pkgState.focus = { type: "list" }; + } else { + pkgState.focus = order[nextIdx]; + } updatePkgManagerView(); }; globalThis.pkg_prev_button = function(): void { if (!pkgState.isOpen) return; + const actions = getActionButtons(); + const hasActions = actions.length > 0; + + // Special handling: from first action button, go back to list + if (hasActions && pkgState.focus.type === "action" && pkgState.focus.index === 0) { + pkgState.focus = { type: "list" }; + updatePkgManagerView(); + return; + } + + // From other action buttons, go to previous action button + if (hasActions && pkgState.focus.type === "action") { + pkgState.focus = { type: "action", index: pkgState.focus.index - 1 }; + updatePkgManagerView(); + return; + } + + // From list with actions, go to last element in order (wrapping backward) + if (hasActions && pkgState.focus.type === "list") { + const order = getFocusOrder(); + // Go to last non-action element (sync button) + for (let i = order.length - 1; i >= 0; i--) { + if (order[i].type !== "action" && order[i].type !== "list") { + pkgState.focus = order[i]; + updatePkgManagerView(); + return; + } + } + } + + // Default behavior: cycle through all elements const order = getFocusOrder(); const currentIdx = getCurrentFocusIndex(); const prevIdx = (currentIdx - 1 + order.length) % order.length; @@ -2513,7 +2678,7 @@ globalThis.pkg_activate = async function(): Promise { return; } - // Handle list selection - move focus to action buttons + // Handle list selection - move focus to first action button if (focus.type === "list") { const items = getFilteredItems(); if (items.length === 0) { @@ -2524,9 +2689,12 @@ globalThis.pkg_activate = async function(): Promise { } return; } - // Move focus to action button - pkgState.focus = { type: "action", index: 0 }; - updatePkgManagerView(); + // Move focus to first action button + const actions = getActionButtons(); + if (actions.length > 0) { + pkgState.focus = { type: "action", index: 0 }; + updatePkgManagerView(); + } return; } @@ -2543,6 +2711,10 @@ globalThis.pkg_activate = async function(): Promise { await updatePackage(item.installedPackage); pkgState.items = buildPackageList(); updatePkgManagerView(); + } else if (actionName === "Reinstall" && item.installedPackage) { + await reinstallPackage(item.installedPackage); + pkgState.items = buildPackageList(); + updatePkgManagerView(); } else if (actionName === "Uninstall" && item.installedPackage) { await removePackage(item.installedPackage); pkgState.items = buildPackageList(); @@ -2551,8 +2723,23 @@ globalThis.pkg_activate = async function(): Promise { pkgState.focus = { type: "list" }; updatePkgManagerView(); } else if (actionName === "Install" && item.registryEntry) { - await installPackage(item.registryEntry.repository, item.name, item.packageType); + const packageName = item.name; + await installPackage(item.registryEntry.repository, packageName, item.packageType); pkgState.items = buildPackageList(); + + // Find the newly installed package in the rebuilt list + const newItems = getFilteredItems(); + const newIndex = newItems.findIndex(i => i.name === packageName && i.installed); + + // Update selection to point to the newly installed package, or stay at current position + if (newIndex >= 0) { + pkgState.selectedIndex = newIndex; + } else { + // Package might be filtered out - reset to first item + pkgState.selectedIndex = 0; + } + + pkgState.focus = { type: "list" }; updatePkgManagerView(); } } diff --git a/crates/fresh-editor/src/app/buffer_management.rs b/crates/fresh-editor/src/app/buffer_management.rs index fd31f2584..297280a45 100644 --- a/crates/fresh-editor/src/app/buffer_management.rs +++ b/crates/fresh-editor/src/app/buffer_management.rs @@ -1322,6 +1322,13 @@ impl Editor { /// Internal helper to close a buffer (shared by close_buffer and force_close_buffer) fn close_buffer_internal(&mut self, id: BufferId) -> anyhow::Result<()> { + // Get file path before closing (for FILE_CLOSED event) + let file_path = self + .buffer_metadata + .get(&id) + .and_then(|m| m.file_path()) + .map(|p| p.to_path_buf()); + // Save file state before closing (for per-file session persistence) self.save_file_state_on_close(id); @@ -1440,6 +1447,16 @@ impl Editor { self.focus_file_explorer(); } + // Emit FILE_CLOSED event for waiting clients + if let Some(path) = file_path { + self.emit_event( + crate::model::control_event::events::FILE_CLOSED.name, + serde_json::json!({ + "path": path.display().to_string(), + }), + ); + } + Ok(()) } diff --git a/crates/fresh-editor/src/config.rs b/crates/fresh-editor/src/config.rs index 37841a656..c02f82815 100644 --- a/crates/fresh-editor/src/config.rs +++ b/crates/fresh-editor/src/config.rs @@ -2232,6 +2232,30 @@ impl Config { }, ); + languages.insert( + "html".to_string(), + LanguageConfig { + extensions: vec!["html".to_string(), "htm".to_string()], + filenames: vec![], + grammar: "html".to_string(), + comment_prefix: Some("