Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ target/
**/*.rs.bk
*.pdb

# ts-rs default output (real bindings go to src/lib/bindings/)
src-tauri/bindings/

# AI local settings
.claude/
.mcp.json
Expand Down
40 changes: 27 additions & 13 deletions src-tauri/src/commands/patcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ pub struct PatcherConfig {
/// If not provided, defaults to 0 (equivalent to `--opts:none` in cslol-tools).
#[ts(optional, type = "number")]
pub flags: Option<u64>,
/// Absolute paths to workshop project directories to include in the overlay.
///
/// These are loaded directly from disk via `FsModContent` and prepended to
/// the enabled mod list (highest priority).
#[ts(optional)]
pub workshop_projects: Option<Vec<String>>,
}

/// Current status of the patcher.
Expand Down Expand Up @@ -151,6 +157,13 @@ fn start_patcher_inner(
let log_file = config.log_file.clone();
let timeout_ms = config.timeout_ms.unwrap_or(DEFAULT_HOOK_TIMEOUT_MS);
let flags = config.flags.unwrap_or(0);
let workshop_paths: Vec<PathBuf> = config
.workshop_projects
.unwrap_or_default()
.iter()
.map(PathBuf::from)
.collect();

let settings_snapshot = settings.0.lock().mutex_err()?.clone();

tracing::info!(
Expand All @@ -171,20 +184,21 @@ fn start_patcher_inner(

let handle = thread::spawn(move || {
// Phase 1: Build overlay (the slow part)
let overlay_root = match overlay::ensure_overlay(&library_clone, &settings_snapshot) {
Ok(root) => root,
Err(e) => {
tracing::error!(error = ?e, "Overlay build failed");
let error_response: AppErrorResponse = e.into();
let _ = library_clone
.app_handle()
.emit("patcher-error", &error_response);
if let Ok(mut s) = state_arc.lock() {
s.phase = PatcherPhase::Idle;
let overlay_root =
match overlay::ensure_overlay(&library_clone, &settings_snapshot, &workshop_paths) {
Ok(root) => root,
Err(e) => {
tracing::error!(error = ?e, "Overlay build failed");
let error_response: AppErrorResponse = e.into();
let _ = library_clone
.app_handle()
.emit("patcher-error", &error_response);
if let Ok(mut s) = state_arc.lock() {
s.phase = PatcherPhase::Idle;
}
return;
}
return;
}
};
};

// Check stop flag between build and patcher loop
if stop_flag.load(Ordering::SeqCst) {
Expand Down
28 changes: 26 additions & 2 deletions src-tauri/src/overlay/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ pub struct OverlayProgress {
/// Ensure the overlay exists and is up-to-date for the current enabled mod set.
///
/// Returns the overlay root directory (the prefix passed to the legacy patcher).
pub fn ensure_overlay(library: &ModLibrary, settings: &Settings) -> AppResult<PathBuf> {
///
/// Workshop project paths (if any) are loaded via `FsModContent` and prepended
/// to the enabled mod list so they take highest priority.
pub fn ensure_overlay(
library: &ModLibrary,
settings: &Settings,
workshop_project_paths: &[PathBuf],
) -> AppResult<PathBuf> {
let storage_dir = library.storage_dir(settings)?;

let game_dir = resolve_game_dir(settings)?;
Expand Down Expand Up @@ -101,7 +108,24 @@ pub fn ensure_overlay(library: &ModLibrary, settings: &Settings) -> AppResult<Pa
);
});

builder.set_enabled_mods(enabled_mods);
let mut all_mods = Vec::new();
for project_path in workshop_project_paths {
let utf8_path = Utf8PathBuf::from_path_buf(project_path.clone()).map_err(|p| {
AppError::Other(format!("Non-UTF-8 workshop project path: {}", p.display()))
})?;
let dir_name = project_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let id = format!("workshop:{}", dir_name);
tracing::info!("Adding workshop project: id={}, path={}", id, utf8_path);
all_mods.push(ltk_overlay::EnabledMod {
id,
content: Box::new(ltk_overlay::FsModContent::new(utf8_path)),
});
}
all_mods.extend(enabled_mods);
builder.set_enabled_mods(all_mods);

builder
.build()
Expand Down
27 changes: 24 additions & 3 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ const spinnerSizeClasses: Record<ButtonSize, string> = {
xl: "text-2xl",
};

const iconSlotSizeClasses: Record<ButtonSize, string> = {
xs: "h-3 w-3",
sm: "h-4 w-4",
md: "h-4 w-4",
lg: "h-5 w-5",
xl: "h-5 w-5",
};

function IconSlot({ children, size }: { children: ReactNode; size: ButtonSize }) {
return (
<span
className={twMerge(
"inline-flex shrink-0 items-center justify-center",
iconSlotSizeClasses[size],
)}
>
{children}
</span>
);
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
Expand Down Expand Up @@ -107,12 +128,12 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{children && <span className="opacity-0">{children}</span>}
</>
))
.with([false, true], () => icon)
.with([false, true], () => <IconSlot size={size}>{icon}</IconSlot>)
.with([false, false], () => (
<>
{leftIcon}
{leftIcon && <IconSlot size={size}>{leftIcon}</IconSlot>}
{children}
{rightIcon}
{rightIcon && <IconSlot size={size}>{rightIcon}</IconSlot>}
</>
))
.exhaustive();
Expand Down
7 changes: 7 additions & 0 deletions src/lib/bindings/PatcherConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,11 @@ export type PatcherConfig = {
* If not provided, defaults to 0 (equivalent to `--opts:none` in cslol-tools).
*/
flags?: number;
/**
* Absolute paths to workshop project directories to include in the overlay.
*
* These are loaded directly from disk via `FsModContent` and prepended to
* the enabled mod list (highest priority).
*/
workshopProjects?: Array<string>;
};
34 changes: 31 additions & 3 deletions src/modules/patcher/components/StatusBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useEffect, useRef } from "react";
import { LuLoader, LuSquare } from "react-icons/lu";

import { Button, Progress } from "@/components";
import type { OverlayProgress } from "@/lib/tauri";
import { usePatcherSessionStore } from "@/stores";

import { useOverlayProgress, usePatcherError, usePatcherStatus, useStopPatcher } from "../api";

Expand All @@ -23,11 +25,32 @@ export function StatusBar() {
const stopPatcher = useStopPatcher();
usePatcherError();

const testingProjects = usePatcherSessionStore((s) => s.testingProjects);
const clearTestingProjects = usePatcherSessionStore((s) => s.clearTestingProjects);

const isBuilding = patcherStatus?.phase === "building";
const isRunning = patcherStatus?.running ?? false;
const isIdle = !isRunning && !isBuilding;

const wasActiveRef = useRef(false);

useEffect(() => {
if (!isIdle) {
wasActiveRef.current = true;
} else if (wasActiveRef.current && testingProjects.length > 0) {
clearTestingProjects();
wasActiveRef.current = false;
}
}, [isIdle, testingProjects, clearTestingProjects]);

// Hide when idle
if (!isRunning && !isBuilding) return null;
if (isIdle) return null;

const testLabel =
testingProjects.length === 1
? `Testing ${testingProjects[0].displayName}`
: testingProjects.length > 1
? `Testing ${testingProjects.length} projects`
: null;

// Building overlay — show full progress
if (isBuilding) {
Expand All @@ -48,6 +71,11 @@ export function StatusBar() {
<div className="flex items-center gap-3">
<LuLoader className="h-4 w-4 shrink-0 animate-spin text-brand-500" />
<span className="shrink-0 text-sm font-medium text-brand-500">Building Overlay</span>
{testLabel && (
<span className="shrink-0 rounded-full bg-brand-500/10 px-2 py-0.5 text-xs font-medium text-brand-400">
{testLabel}
</span>
)}
<span className="text-sm text-surface-300">{label}</span>
<div className="flex-1" />
{counter && (
Expand All @@ -72,7 +100,7 @@ export function StatusBar() {
return (
<div className="animate-in slide-in-from-bottom-2 flex items-center border-t-2 border-green-500 bg-surface-950 px-4 py-2">
<span className="mr-2 h-2 w-2 shrink-0 animate-pulse rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-500">Patcher running</span>
<span className="text-sm font-medium text-green-500">{testLabel ?? "Patcher running"}</span>
<div className="flex-1" />
<Button
variant="ghost"
Expand Down
1 change: 1 addition & 0 deletions src/modules/patcher/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
patcherKeys,
useOverlayProgress,
usePatcherError,
usePatcherStatus,
Expand Down
2 changes: 2 additions & 0 deletions src/modules/workshop/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { workshopKeys } from "./keys";
export { useCreateProject } from "./useCreateProject";
export { useDeleteProject } from "./useDeleteProject";
export { useFantomeImportProgress } from "./useFantomeImportProgress";
export { useFilteredProjects } from "./useFilteredProjects";
export { useGitImportProgress } from "./useGitImportProgress";
export { useImportFromFantome } from "./useImportFromFantome";
export { useImportFromGitRepo } from "./useImportFromGitRepo";
Expand All @@ -14,6 +15,7 @@ export { useRenameProject } from "./useRenameProject";
export { useSaveProjectConfig } from "./useSaveProjectConfig";
export { useSaveStringOverrides } from "./useSaveStringOverrides";
export { useSetProjectThumbnail } from "./useSetProjectThumbnail";
export { useTestProjects } from "./useTestProject";
export { useValidateProject, validateProjectOptions } from "./useValidateProject";
export { useWorkshopProject, workshopProjectOptions } from "./useWorkshopProject";
export { useWorkshopProjects, workshopProjectsOptions } from "./useWorkshopProjects";
20 changes: 20 additions & 0 deletions src/modules/workshop/api/useFilteredProjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useMemo } from "react";

import { useWorkshopViewStore } from "@/stores";

import { useWorkshopProjects } from "./useWorkshopProjects";

export function useFilteredProjects() {
const { data: projects = [] } = useWorkshopProjects();
const searchQuery = useWorkshopViewStore((s) => s.searchQuery);

return useMemo(() => {
if (!searchQuery) return projects;
const query = searchQuery.toLowerCase();
return projects.filter(
(project) =>
project.displayName.toLowerCase().includes(query) ||
project.name.toLowerCase().includes(query),
);
}, [projects, searchQuery]);
}
77 changes: 18 additions & 59 deletions src/modules/workshop/api/useProjectActions.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,29 @@
import type { NavigateFn } from "@tanstack/react-router";
import { useState } from "react";
import { api, type WorkshopProject } from "@/lib/tauri";
import { useWorkshopDialogsStore } from "@/stores";

import { api, type PackResult, type WorkshopProject } from "@/lib/tauri";
import { useTestProjects } from "./useTestProject";

import { useDeleteProject } from "./useDeleteProject";
import { usePackProject } from "./usePackProject";
import { useValidateProject } from "./useValidateProject";
export function useProjectActions(project: WorkshopProject | undefined) {
const testProjects = useTestProjects();
const openPackDialog = useWorkshopDialogsStore((s) => s.openPackDialog);
const openDeleteDialog = useWorkshopDialogsStore((s) => s.openDeleteDialog);

export function useProjectActions(project: WorkshopProject | undefined, navigate: NavigateFn) {
const [packDialogOpen, setPackDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [packResult, setPackResult] = useState<PackResult | null>(null);

const deleteProject = useDeleteProject();
const packProject = usePackProject();
const { data: validation, isLoading: validationLoading } = useValidateProject(
project?.path ?? "",
packDialogOpen,
);

function handlePack(format: "modpkg" | "fantome") {
function handleTestProject() {
if (!project) return;
packProject.mutate(
{ projectPath: project.path, format },
{
onSuccess: setPackResult,
onError: (err) => console.error("Failed to pack project:", err.message),
},
testProjects.mutate(
{ projects: [{ path: project.path, displayName: project.displayName }] },
{ onError: (err) => console.error("Failed to test project:", err.message) },
);
}

function handleOpenPackDialog() {
setPackResult(null);
setPackDialogOpen(true);
}

function handleClosePackDialog() {
setPackDialogOpen(false);
setPackResult(null);
}

function handleDeleteProject() {
if (!project) return;
deleteProject.mutate(project.path, {
onSuccess: () => {
navigate({ to: "/workshop" });
},
onError: (err) => console.error("Failed to delete project:", err.message),
});
}

function openDeleteDialog() {
setDeleteDialogOpen(true);
openPackDialog(project);
}

function closeDeleteDialog() {
setDeleteDialogOpen(false);
function handleOpenDeleteDialog() {
if (!project) return;
openDeleteDialog(project);
}

async function handleOpenLocation() {
Expand All @@ -68,19 +36,10 @@ export function useProjectActions(project: WorkshopProject | undefined, navigate
}

return {
packDialogOpen,
packResult,
deleteDialogOpen,
validation: validation ?? null,
validationLoading,
isPacking: packProject.isPending,
isDeleting: deleteProject.isPending,
handlePack,
isTesting: testProjects.isPending,
handleTestProject,
handleOpenPackDialog,
handleClosePackDialog,
handleDeleteProject,
handleOpenDeleteDialog,
handleOpenLocation,
openDeleteDialog,
closeDeleteDialog,
};
}
Loading