Skip to content

Commit 5309d16

Browse files
authored
Merge pull request #21 from LeagueToolkit/workshop-mod-testing
feat(workshop): implement mod project testing in the workshop
2 parents 32d611b + 637b42b commit 5309d16

30 files changed

+836
-584
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ target/
2626
**/*.rs.bk
2727
*.pdb
2828

29+
# ts-rs default output (real bindings go to src/lib/bindings/)
30+
src-tauri/bindings/
31+
2932
# AI local settings
3033
.claude/
3134
.mcp.json

src-tauri/src/commands/patcher.rs

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ pub struct PatcherConfig {
3131
/// If not provided, defaults to 0 (equivalent to `--opts:none` in cslol-tools).
3232
#[ts(optional, type = "number")]
3333
pub flags: Option<u64>,
34+
/// Absolute paths to workshop project directories to include in the overlay.
35+
///
36+
/// These are loaded directly from disk via `FsModContent` and prepended to
37+
/// the enabled mod list (highest priority).
38+
#[ts(optional)]
39+
pub workshop_projects: Option<Vec<String>>,
3440
}
3541

3642
/// Current status of the patcher.
@@ -151,6 +157,13 @@ fn start_patcher_inner(
151157
let log_file = config.log_file.clone();
152158
let timeout_ms = config.timeout_ms.unwrap_or(DEFAULT_HOOK_TIMEOUT_MS);
153159
let flags = config.flags.unwrap_or(0);
160+
let workshop_paths: Vec<PathBuf> = config
161+
.workshop_projects
162+
.unwrap_or_default()
163+
.iter()
164+
.map(PathBuf::from)
165+
.collect();
166+
154167
let settings_snapshot = settings.0.lock().mutex_err()?.clone();
155168

156169
tracing::info!(
@@ -171,20 +184,21 @@ fn start_patcher_inner(
171184

172185
let handle = thread::spawn(move || {
173186
// Phase 1: Build overlay (the slow part)
174-
let overlay_root = match overlay::ensure_overlay(&library_clone, &settings_snapshot) {
175-
Ok(root) => root,
176-
Err(e) => {
177-
tracing::error!(error = ?e, "Overlay build failed");
178-
let error_response: AppErrorResponse = e.into();
179-
let _ = library_clone
180-
.app_handle()
181-
.emit("patcher-error", &error_response);
182-
if let Ok(mut s) = state_arc.lock() {
183-
s.phase = PatcherPhase::Idle;
187+
let overlay_root =
188+
match overlay::ensure_overlay(&library_clone, &settings_snapshot, &workshop_paths) {
189+
Ok(root) => root,
190+
Err(e) => {
191+
tracing::error!(error = ?e, "Overlay build failed");
192+
let error_response: AppErrorResponse = e.into();
193+
let _ = library_clone
194+
.app_handle()
195+
.emit("patcher-error", &error_response);
196+
if let Ok(mut s) = state_arc.lock() {
197+
s.phase = PatcherPhase::Idle;
198+
}
199+
return;
184200
}
185-
return;
186-
}
187-
};
201+
};
188202

189203
// Check stop flag between build and patcher loop
190204
if stop_flag.load(Ordering::SeqCst) {

src-tauri/src/overlay/mod.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@ pub struct OverlayProgress {
3333
/// Ensure the overlay exists and is up-to-date for the current enabled mod set.
3434
///
3535
/// Returns the overlay root directory (the prefix passed to the legacy patcher).
36-
pub fn ensure_overlay(library: &ModLibrary, settings: &Settings) -> AppResult<PathBuf> {
36+
///
37+
/// Workshop project paths (if any) are loaded via `FsModContent` and prepended
38+
/// to the enabled mod list so they take highest priority.
39+
pub fn ensure_overlay(
40+
library: &ModLibrary,
41+
settings: &Settings,
42+
workshop_project_paths: &[PathBuf],
43+
) -> AppResult<PathBuf> {
3744
let storage_dir = library.storage_dir(settings)?;
3845

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

104-
builder.set_enabled_mods(enabled_mods);
111+
let mut all_mods = Vec::new();
112+
for project_path in workshop_project_paths {
113+
let utf8_path = Utf8PathBuf::from_path_buf(project_path.clone()).map_err(|p| {
114+
AppError::Other(format!("Non-UTF-8 workshop project path: {}", p.display()))
115+
})?;
116+
let dir_name = project_path
117+
.file_name()
118+
.and_then(|n| n.to_str())
119+
.unwrap_or("unknown");
120+
let id = format!("workshop:{}", dir_name);
121+
tracing::info!("Adding workshop project: id={}, path={}", id, utf8_path);
122+
all_mods.push(ltk_overlay::EnabledMod {
123+
id,
124+
content: Box::new(ltk_overlay::FsModContent::new(utf8_path)),
125+
});
126+
}
127+
all_mods.extend(enabled_mods);
128+
builder.set_enabled_mods(all_mods);
105129

106130
builder
107131
.build()

src/components/Button.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ const spinnerSizeClasses: Record<ButtonSize, string> = {
7272
xl: "text-2xl",
7373
};
7474

75+
const iconSlotSizeClasses: Record<ButtonSize, string> = {
76+
xs: "h-3 w-3",
77+
sm: "h-4 w-4",
78+
md: "h-4 w-4",
79+
lg: "h-5 w-5",
80+
xl: "h-5 w-5",
81+
};
82+
83+
function IconSlot({ children, size }: { children: ReactNode; size: ButtonSize }) {
84+
return (
85+
<span
86+
className={twMerge(
87+
"inline-flex shrink-0 items-center justify-center",
88+
iconSlotSizeClasses[size],
89+
)}
90+
>
91+
{children}
92+
</span>
93+
);
94+
}
95+
7596
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
7697
(
7798
{
@@ -107,12 +128,12 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
107128
{children && <span className="opacity-0">{children}</span>}
108129
</>
109130
))
110-
.with([false, true], () => icon)
131+
.with([false, true], () => <IconSlot size={size}>{icon}</IconSlot>)
111132
.with([false, false], () => (
112133
<>
113-
{leftIcon}
134+
{leftIcon && <IconSlot size={size}>{leftIcon}</IconSlot>}
114135
{children}
115-
{rightIcon}
136+
{rightIcon && <IconSlot size={size}>{rightIcon}</IconSlot>}
116137
</>
117138
))
118139
.exhaustive();

src/lib/bindings/PatcherConfig.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@ export type PatcherConfig = {
1818
* If not provided, defaults to 0 (equivalent to `--opts:none` in cslol-tools).
1919
*/
2020
flags?: number;
21+
/**
22+
* Absolute paths to workshop project directories to include in the overlay.
23+
*
24+
* These are loaded directly from disk via `FsModContent` and prepended to
25+
* the enabled mod list (highest priority).
26+
*/
27+
workshopProjects?: Array<string>;
2128
};

src/modules/patcher/components/StatusBar.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { useEffect, useRef } from "react";
12
import { LuLoader, LuSquare } from "react-icons/lu";
23

34
import { Button, Progress } from "@/components";
45
import type { OverlayProgress } from "@/lib/tauri";
6+
import { usePatcherSessionStore } from "@/stores";
57

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

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

28+
const testingProjects = usePatcherSessionStore((s) => s.testingProjects);
29+
const clearTestingProjects = usePatcherSessionStore((s) => s.clearTestingProjects);
30+
2631
const isBuilding = patcherStatus?.phase === "building";
2732
const isRunning = patcherStatus?.running ?? false;
33+
const isIdle = !isRunning && !isBuilding;
34+
35+
const wasActiveRef = useRef(false);
36+
37+
useEffect(() => {
38+
if (!isIdle) {
39+
wasActiveRef.current = true;
40+
} else if (wasActiveRef.current && testingProjects.length > 0) {
41+
clearTestingProjects();
42+
wasActiveRef.current = false;
43+
}
44+
}, [isIdle, testingProjects, clearTestingProjects]);
2845

29-
// Hide when idle
30-
if (!isRunning && !isBuilding) return null;
46+
if (isIdle) return null;
47+
48+
const testLabel =
49+
testingProjects.length === 1
50+
? `Testing ${testingProjects[0].displayName}`
51+
: testingProjects.length > 1
52+
? `Testing ${testingProjects.length} projects`
53+
: null;
3154

3255
// Building overlay — show full progress
3356
if (isBuilding) {
@@ -48,6 +71,11 @@ export function StatusBar() {
4871
<div className="flex items-center gap-3">
4972
<LuLoader className="h-4 w-4 shrink-0 animate-spin text-brand-500" />
5073
<span className="shrink-0 text-sm font-medium text-brand-500">Building Overlay</span>
74+
{testLabel && (
75+
<span className="shrink-0 rounded-full bg-brand-500/10 px-2 py-0.5 text-xs font-medium text-brand-400">
76+
{testLabel}
77+
</span>
78+
)}
5179
<span className="text-sm text-surface-300">{label}</span>
5280
<div className="flex-1" />
5381
{counter && (
@@ -72,7 +100,7 @@ export function StatusBar() {
72100
return (
73101
<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">
74102
<span className="mr-2 h-2 w-2 shrink-0 animate-pulse rounded-full bg-green-500" />
75-
<span className="text-sm font-medium text-green-500">Patcher running</span>
103+
<span className="text-sm font-medium text-green-500">{testLabel ?? "Patcher running"}</span>
76104
<div className="flex-1" />
77105
<Button
78106
variant="ghost"

src/modules/patcher/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
2+
patcherKeys,
23
useOverlayProgress,
34
usePatcherError,
45
usePatcherStatus,

src/modules/workshop/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { workshopKeys } from "./keys";
22
export { useCreateProject } from "./useCreateProject";
33
export { useDeleteProject } from "./useDeleteProject";
44
export { useFantomeImportProgress } from "./useFantomeImportProgress";
5+
export { useFilteredProjects } from "./useFilteredProjects";
56
export { useGitImportProgress } from "./useGitImportProgress";
67
export { useImportFromFantome } from "./useImportFromFantome";
78
export { useImportFromGitRepo } from "./useImportFromGitRepo";
@@ -14,6 +15,7 @@ export { useRenameProject } from "./useRenameProject";
1415
export { useSaveProjectConfig } from "./useSaveProjectConfig";
1516
export { useSaveStringOverrides } from "./useSaveStringOverrides";
1617
export { useSetProjectThumbnail } from "./useSetProjectThumbnail";
18+
export { useTestProjects } from "./useTestProject";
1719
export { useValidateProject, validateProjectOptions } from "./useValidateProject";
1820
export { useWorkshopProject, workshopProjectOptions } from "./useWorkshopProject";
1921
export { useWorkshopProjects, workshopProjectsOptions } from "./useWorkshopProjects";
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useMemo } from "react";
2+
3+
import { useWorkshopViewStore } from "@/stores";
4+
5+
import { useWorkshopProjects } from "./useWorkshopProjects";
6+
7+
export function useFilteredProjects() {
8+
const { data: projects = [] } = useWorkshopProjects();
9+
const searchQuery = useWorkshopViewStore((s) => s.searchQuery);
10+
11+
return useMemo(() => {
12+
if (!searchQuery) return projects;
13+
const query = searchQuery.toLowerCase();
14+
return projects.filter(
15+
(project) =>
16+
project.displayName.toLowerCase().includes(query) ||
17+
project.name.toLowerCase().includes(query),
18+
);
19+
}, [projects, searchQuery]);
20+
}
Lines changed: 18 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,29 @@
1-
import type { NavigateFn } from "@tanstack/react-router";
2-
import { useState } from "react";
1+
import { api, type WorkshopProject } from "@/lib/tauri";
2+
import { useWorkshopDialogsStore } from "@/stores";
33

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

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

10-
export function useProjectActions(project: WorkshopProject | undefined, navigate: NavigateFn) {
11-
const [packDialogOpen, setPackDialogOpen] = useState(false);
12-
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
13-
const [packResult, setPackResult] = useState<PackResult | null>(null);
14-
15-
const deleteProject = useDeleteProject();
16-
const packProject = usePackProject();
17-
const { data: validation, isLoading: validationLoading } = useValidateProject(
18-
project?.path ?? "",
19-
packDialogOpen,
20-
);
21-
22-
function handlePack(format: "modpkg" | "fantome") {
11+
function handleTestProject() {
2312
if (!project) return;
24-
packProject.mutate(
25-
{ projectPath: project.path, format },
26-
{
27-
onSuccess: setPackResult,
28-
onError: (err) => console.error("Failed to pack project:", err.message),
29-
},
13+
testProjects.mutate(
14+
{ projects: [{ path: project.path, displayName: project.displayName }] },
15+
{ onError: (err) => console.error("Failed to test project:", err.message) },
3016
);
3117
}
3218

3319
function handleOpenPackDialog() {
34-
setPackResult(null);
35-
setPackDialogOpen(true);
36-
}
37-
38-
function handleClosePackDialog() {
39-
setPackDialogOpen(false);
40-
setPackResult(null);
41-
}
42-
43-
function handleDeleteProject() {
4420
if (!project) return;
45-
deleteProject.mutate(project.path, {
46-
onSuccess: () => {
47-
navigate({ to: "/workshop" });
48-
},
49-
onError: (err) => console.error("Failed to delete project:", err.message),
50-
});
51-
}
52-
53-
function openDeleteDialog() {
54-
setDeleteDialogOpen(true);
21+
openPackDialog(project);
5522
}
5623

57-
function closeDeleteDialog() {
58-
setDeleteDialogOpen(false);
24+
function handleOpenDeleteDialog() {
25+
if (!project) return;
26+
openDeleteDialog(project);
5927
}
6028

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

7038
return {
71-
packDialogOpen,
72-
packResult,
73-
deleteDialogOpen,
74-
validation: validation ?? null,
75-
validationLoading,
76-
isPacking: packProject.isPending,
77-
isDeleting: deleteProject.isPending,
78-
handlePack,
39+
isTesting: testProjects.isPending,
40+
handleTestProject,
7941
handleOpenPackDialog,
80-
handleClosePackDialog,
81-
handleDeleteProject,
42+
handleOpenDeleteDialog,
8243
handleOpenLocation,
83-
openDeleteDialog,
84-
closeDeleteDialog,
8544
};
8645
}

0 commit comments

Comments
 (0)