Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4eabe2d
feat: add --wait flag to open-file for $EDITOR use case
sinelaw Feb 5, 2026
da873b4
docs: document --wait flag for open-file command
sinelaw Feb 5, 2026
a285c40
fix: use semantic waiting in e2e server test
sinelaw Feb 5, 2026
431c1d7
fix: address flaky test patterns
sinelaw Feb 5, 2026
b63005e
use -j=16 in ci nextest
sinelaw Jan 4, 2026
26168dd
fix: write fresh-client.log to proper log directory
sinelaw Feb 5, 2026
38e0e4c
fix: use consistent log directory and PID naming for all logs
sinelaw Feb 5, 2026
af4e09e
fix: check condition after every read in server tests
sinelaw Feb 5, 2026
1f47ea5
feat(pkg): Add Reinstall button to package manager
sinelaw Feb 5, 2026
31876ec
fix(pkg): Update selection after installing package
sinelaw Feb 5, 2026
7f1311e
fix: support reinstalling packages from local paths
sinelaw Feb 5, 2026
2113aab
fix(pkg): Add trailing space to reinstall confirmation prompt
sinelaw Feb 5, 2026
3e91986
Mark test_colors_displayed_in_hex_format as flaky
sinelaw Feb 5, 2026
3396b32
debug: add logging to diagnose Windows test hangs
sinelaw Feb 5, 2026
1eb9d79
Reduce hopefully time of this test: test_line_iterator_large_single_l…
sinelaw Feb 5, 2026
35fde31
feat: add HTML language config with Prettier formatter
sinelaw Feb 5, 2026
34dd72f
feat: add getPluginDir() API for plugins to locate their directory
sinelaw Feb 5, 2026
e84785a
feat(pkg): Tab key cycles through action buttons when package selected
sinelaw Feb 5, 2026
3bab721
fix(pkg): Enter on list item executes primary action
sinelaw Feb 5, 2026
fcbf3a7
feat(pkg): Tab cycles through actions then wraps to list
sinelaw Feb 5, 2026
be2a5f2
fix(pkg): Explicitly set pkg-manager mode for keybindings
sinelaw Feb 5, 2026
18873f2
debug(pkg): Add logging to Tab handler
sinelaw Feb 5, 2026
3345b4e
fix(pkg): Enter on list moves focus, doesn't execute
sinelaw Feb 5, 2026
03b5d28
feat(pkg): Add confirmation prompt for uninstall
sinelaw Feb 5, 2026
0b06929
fix(pkg): Reset editor mode to normal when closing
sinelaw Feb 5, 2026
67aac58
fix(pkg): Skip uninstall confirmation during reinstall
sinelaw Feb 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions crates/fresh-editor/plugins/lib/fresh.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
223 changes: 205 additions & 18 deletions crates/fresh-editor/plugins/pkg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,15 +551,28 @@ function getInstalledPackages(type: "plugin" | "theme" | "language" | "bundle"):
const manifestPath = editor.pathJoin(pkgPath, "package.json");
const manifest = readJsonFile<PackageManifest>(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();
}
}
}
}
Expand All @@ -570,7 +583,7 @@ function getInstalledPackages(type: "plugin" | "theme" | "language" | "bundle"):
type,
source,
version: manifest?.version || "unknown",
manifest
manifest: manifest || undefined
});
}
}
Expand Down Expand Up @@ -1351,7 +1364,20 @@ async function updatePackage(pkg: InstalledPackage): Promise<boolean> {
/**
* Remove a package
*/
async function removePackage(pkg: InstalledPackage): Promise<boolean> {
async function removePackage(pkg: InstalledPackage, skipConfirmation = false): Promise<boolean> {
// 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)
Expand Down Expand Up @@ -1383,6 +1409,65 @@ async function removePackage(pkg: InstalledPackage): Promise<boolean> {
}
}

/**
* 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<boolean> {
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
*/
Expand Down Expand Up @@ -1519,6 +1604,7 @@ interface PackageListItem {
author?: string;
license?: string;
repository?: string;
source?: string;
stars?: number;
downloads?: number;
keywords?: string[];
Expand Down Expand Up @@ -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"];
}
Expand Down Expand Up @@ -2360,6 +2457,9 @@ async function openPackageManager(): Promise<void> {
pkgState.bufferId = result.bufferId;
pkgState.isOpen = true;

// Ensure pkg-manager mode is active for keybindings
editor.setEditorMode("pkg-manager");

// Apply initial highlighting
applyPkgManagerHighlighting();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2513,7 +2678,7 @@ globalThis.pkg_activate = async function(): Promise<void> {
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) {
Expand All @@ -2524,9 +2689,12 @@ globalThis.pkg_activate = async function(): Promise<void> {
}
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;
}

Expand All @@ -2543,6 +2711,10 @@ globalThis.pkg_activate = async function(): Promise<void> {
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();
Expand All @@ -2551,8 +2723,23 @@ globalThis.pkg_activate = async function(): Promise<void> {
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();
}
}
Expand Down
17 changes: 17 additions & 0 deletions crates/fresh-editor/src/app/buffer_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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(())
}

Expand Down
Loading
Loading