Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions src/components/Icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge";

export type IconSize = "xs" | "sm" | "md" | "lg";

export interface IconProps {
icon: IconType;
size?: IconSize;
className?: string;
}

const sizeClasses: Record<IconSize, string> = {
xs: "h-3 w-3",
sm: "h-3.5 w-3.5",
md: "h-4 w-4",
lg: "h-5 w-5",
};

export function Icon({ icon: IconComponent, size = "md", className }: IconProps) {
return <IconComponent className={twMerge(sizeClasses[size], className)} />;
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./Checkbox";
export * from "./Combobox";
export * from "./Dialog";
export * from "./FormField";
export * from "./Icon";
export * from "./Menu";
export * from "./MultiSelect";
export * from "./NavTabs";
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]);
}
Loading