server folder improvements
This commit is contained in:
+31
-14
@@ -48,7 +48,7 @@ import tools.jackson.databind.ObjectMapper;
|
||||
public class PipelineDirectoryProcessor {
|
||||
|
||||
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
|
||||
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("\\\\?watchedFolders");
|
||||
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("[/\\\\]watchedFolders");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiDocService apiDocService;
|
||||
@@ -105,7 +105,7 @@ public class PipelineDirectoryProcessor {
|
||||
processedDirsInScan.get().clear();
|
||||
try {
|
||||
handleDirectory(dir.toAbsolutePath().normalize());
|
||||
} catch (IOException e) {
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing directory: {}", dir, e);
|
||||
} finally {
|
||||
processedDirsInScan.remove();
|
||||
@@ -153,7 +153,8 @@ public class PipelineDirectoryProcessor {
|
||||
&& !"processing".equals(dirName)
|
||||
&& !"processed".equals(dirName)
|
||||
&& !"error".equals(dirName)) {
|
||||
// Skip server-managed folders — they are processed on-demand via
|
||||
// Skip server-managed folders — they are processed on-demand
|
||||
// via
|
||||
// the trigger endpoint; session.json marks them as managed.
|
||||
if (Files.exists(dir.resolve("session.json"))) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
@@ -241,7 +242,7 @@ public class PipelineDirectoryProcessor {
|
||||
validateOperation(operation);
|
||||
File[] files = collectFilesForProcessing(dir, jsonFile, operation);
|
||||
if (files.length == 0) {
|
||||
log.debug("No files detected for {} ", dir);
|
||||
log.info("No files ready for processing in {}", dir);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -274,6 +275,13 @@ public class PipelineDirectoryProcessor {
|
||||
operation.getOperation(),
|
||||
inputExtensions);
|
||||
|
||||
if (inputExtensions == null) {
|
||||
log.warn(
|
||||
"No input extension info found for operation {} — skipping directory {}",
|
||||
operation.getOperation(),
|
||||
dir);
|
||||
return new File[0];
|
||||
}
|
||||
boolean allowAllFiles = inputExtensions.contains("ALL");
|
||||
// Server-managed folders (session.json present) only process files that have a
|
||||
// corresponding .ready marker, preventing partial-upload races.
|
||||
@@ -313,7 +321,8 @@ public class PipelineDirectoryProcessor {
|
||||
// Check against allowed extensions
|
||||
String extension =
|
||||
fname.contains(".")
|
||||
? fname.substring(fname.lastIndexOf('.') + 1)
|
||||
? fname.substring(
|
||||
fname.lastIndexOf('.') + 1)
|
||||
.toLowerCase(Locale.ROOT)
|
||||
: "";
|
||||
boolean isAllowed =
|
||||
@@ -331,6 +340,9 @@ public class PipelineDirectoryProcessor {
|
||||
.map(Path::toAbsolutePath)
|
||||
.filter(
|
||||
path -> {
|
||||
// Server-managed folders use the .ready marker to
|
||||
// guarantee upload completion — skip the timestamp delay.
|
||||
if (isServerManaged) return true;
|
||||
boolean isReady =
|
||||
fileMonitor.isFileReadyForProcessing(path);
|
||||
if (!isReady) {
|
||||
@@ -382,9 +394,10 @@ public class PipelineDirectoryProcessor {
|
||||
if (moved) {
|
||||
filesToProcess.add(targetPath.toFile());
|
||||
// Remove the .ready marker now that the file is safely in processingDir
|
||||
String stem = file.getName().contains(".")
|
||||
? file.getName().substring(0, file.getName().lastIndexOf('.'))
|
||||
: file.getName();
|
||||
String stem =
|
||||
file.getName().contains(".")
|
||||
? file.getName().substring(0, file.getName().lastIndexOf('.'))
|
||||
: file.getName();
|
||||
try {
|
||||
Files.deleteIfExists(file.toPath().getParent().resolve(stem + ".ready"));
|
||||
} catch (IOException ignore) {
|
||||
@@ -504,13 +517,17 @@ public class PipelineDirectoryProcessor {
|
||||
}
|
||||
|
||||
private Path determineOutputPath(PipelineConfig config, Path dir) {
|
||||
String rawOutputDir =
|
||||
config.getOutputDir()
|
||||
.replace("{outputFolder}", finishedFoldersDir)
|
||||
.replace("{folderName}", dir.toString());
|
||||
// Only strip the watchedFolders segment for relative (legacy) output paths.
|
||||
// Server-managed folders set an absolute output path — leave it untouched so
|
||||
// output lands in the correct {folderId}/processed directory.
|
||||
String outputDir =
|
||||
WATCHED_FOLDERS_PATTERN
|
||||
.matcher(
|
||||
config.getOutputDir()
|
||||
.replace("{outputFolder}", finishedFoldersDir)
|
||||
.replace("{folderName}", dir.toString()))
|
||||
.replaceAll("");
|
||||
Paths.get(rawOutputDir).isAbsolute()
|
||||
? rawOutputDir
|
||||
: WATCHED_FOLDERS_PATTERN.matcher(rawOutputDir).replaceAll("");
|
||||
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,17 +14,17 @@ public sealed interface PipelineEvent {
|
||||
record JobFailed(String sessionId, String jobId, String error) implements PipelineEvent {}
|
||||
|
||||
/**
|
||||
* Fired by {@link
|
||||
* stirling.software.SPDF.controller.api.pipeline.PipelineDirectoryProcessor} when a server
|
||||
* watch folder batch succeeds. {@code outputFiles} are filenames of the form {@code
|
||||
* {fileId}.{ext}} — the frontend strips the extension to recover the IDB fileId.
|
||||
* Fired by {@link stirling.software.SPDF.controller.api.pipeline.PipelineDirectoryProcessor}
|
||||
* when a server watch folder batch succeeds. {@code outputFiles} are filenames of the form
|
||||
* {@code {fileId}.{ext}} — the frontend strips the extension to recover the IDB fileId.
|
||||
*/
|
||||
record FolderCompleted(String sessionId, String folderId, List<String> outputFiles)
|
||||
implements PipelineEvent {}
|
||||
|
||||
/**
|
||||
* Fired when a server watch folder batch fails (pipeline reported errors). {@code failedFileIds}
|
||||
* are the IDB fileIds extracted from the input filenames ({@code {fileId}.{ext}}).
|
||||
* Fired when a server watch folder batch fails (pipeline reported errors). {@code
|
||||
* failedFileIds} are the IDB fileIds extracted from the input filenames ({@code
|
||||
* {fileId}.{ext}}).
|
||||
*/
|
||||
record FolderError(String sessionId, String folderId, List<String> failedFileIds)
|
||||
implements PipelineEvent {}
|
||||
|
||||
@@ -14,8 +14,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* is set to {@code COMPLETED} or {@code FAILED}, so any thread that observes the terminal status is
|
||||
* guaranteed (by the Java Memory Model volatile write/read ordering) to see the result fields.
|
||||
*
|
||||
* <p>Results are stored as a temp file on disk rather than in the JVM heap, preventing large
|
||||
* PDFs from causing memory pressure when many jobs are in flight simultaneously.
|
||||
* <p>Results are stored as a temp file on disk rather than in the JVM heap, preventing large PDFs
|
||||
* from causing memory pressure when many jobs are in flight simultaneously.
|
||||
*/
|
||||
@Getter
|
||||
@Slf4j
|
||||
@@ -38,6 +38,7 @@ public class PipelineJob {
|
||||
private volatile Status status = Status.PENDING;
|
||||
|
||||
private String resultFilename;
|
||||
|
||||
/** Temp file holding the result bytes; deleted when the job is cleaned up. */
|
||||
private Path resultPath;
|
||||
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
package stirling.software.SPDF.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Persisted to session.json inside each server watch folder.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code sessionId} — routes SSE notifications to the current browser tab.</li>
|
||||
* <li>{@code folderId} — folder UUID (redundant but avoids directory traversal).</li>
|
||||
* <li>{@code outputTtlHours} — delete output files older than this many hours; {@code null} = keep forever.</li>
|
||||
* <li>{@code deleteOutputOnDownload} — if {@code true}, the frontend sends a DELETE after downloading an output file.</li>
|
||||
* <li>{@code sessionId} — routes SSE notifications to the current browser tab.
|
||||
* <li>{@code folderId} — folder UUID (redundant but avoids directory traversal).
|
||||
* <li>{@code outputTtlHours} — delete output files older than this many hours; {@code null} =
|
||||
* keep forever.
|
||||
* <li>{@code deleteOutputOnDownload} — if {@code true}, the frontend sends a DELETE after
|
||||
* downloading an output file.
|
||||
* </ul>
|
||||
*/
|
||||
public record SessionConfig(
|
||||
String sessionId,
|
||||
String folderId,
|
||||
Integer outputTtlHours,
|
||||
Boolean deleteOutputOnDownload) {
|
||||
@JsonProperty("sessionId") String sessionId,
|
||||
@JsonProperty("folderId") String folderId,
|
||||
@JsonProperty("outputTtlHours") Integer outputTtlHours,
|
||||
@JsonProperty("deleteOutputOnDownload") Boolean deleteOutputOnDownload) {
|
||||
|
||||
/** Compact constructor — treat null booleans as false. */
|
||||
public SessionConfig {
|
||||
if (deleteOutputOnDownload == null) deleteOutputOnDownload = false;
|
||||
@JsonCreator
|
||||
public SessionConfig(
|
||||
@JsonProperty("sessionId") String sessionId,
|
||||
@JsonProperty("folderId") String folderId,
|
||||
@JsonProperty("outputTtlHours") Integer outputTtlHours,
|
||||
@JsonProperty("deleteOutputOnDownload") Boolean deleteOutputOnDownload) {
|
||||
this.sessionId = sessionId;
|
||||
this.folderId = folderId;
|
||||
this.outputTtlHours = outputTtlHours;
|
||||
this.deleteOutputOnDownload =
|
||||
deleteOutputOnDownload == null ? false : deleteOutputOnDownload;
|
||||
}
|
||||
|
||||
/** Convenience accessor with a sensible default. */
|
||||
|
||||
@@ -7,17 +7,20 @@ import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar';
|
||||
import { ViewerProvider } from '@app/contexts/ViewerContext';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
fileId: FileId | null;
|
||||
fileId?: FileId | null;
|
||||
/** Pass a File directly (e.g. server-folder outputs not stored in IDB). */
|
||||
file?: File | null;
|
||||
fileName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModalProps) {
|
||||
export function FilePreviewModal({ fileId, file: fileProp, fileName, onClose }: FilePreviewModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileProp) { setFile(fileProp); setError(false); setLoading(false); return; }
|
||||
if (!fileId) { setFile(null); setError(false); setLoading(false); return; }
|
||||
setError(false);
|
||||
setLoading(true);
|
||||
@@ -28,11 +31,13 @@ export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModal
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [fileId]);
|
||||
}, [fileId, fileProp]);
|
||||
|
||||
const opened = !!(fileId || fileProp);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={!!fileId}
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={fileName}
|
||||
size="90%"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Switch,
|
||||
Select,
|
||||
Box,
|
||||
Collapse,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SmartFolder } from '@app/types/smartFolders';
|
||||
@@ -94,7 +95,6 @@ export function SmartFolderManagementModal({
|
||||
}, [opened]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [name, setName] = useState(editFolder?.name ?? '');
|
||||
const [description, setDescription] = useState(editFolder?.description ?? '');
|
||||
const [icon, setIcon] = useState(editFolder?.icon ?? 'FolderIcon');
|
||||
const [accentColor, setAccentColor] = useState(editFolder?.accentColor ?? '#3b82f6');
|
||||
const [maxRetries, setMaxRetries] = useState<number>(editFolder?.maxRetries ?? 3);
|
||||
@@ -114,12 +114,12 @@ export function SmartFolderManagementModal({
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [automationError, setAutomationError] = useState('');
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(isEditMode);
|
||||
|
||||
const automationSaveTrigger = useRef<(() => void) | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setName(editFolder?.name ?? '');
|
||||
setDescription(editFolder?.description ?? '');
|
||||
setIcon(editFolder?.icon ?? 'FolderIcon');
|
||||
setAccentColor(editFolder?.accentColor ?? '#3b82f6');
|
||||
setMaxRetries(editFolder?.maxRetries ?? 3);
|
||||
@@ -131,6 +131,7 @@ export function SmartFolderManagementModal({
|
||||
setOutputTtlHours(editFolder?.outputTtlHours != null ? String(editFolder.outputTtlHours) : 'forever');
|
||||
setDeleteOutputOnDownload(editFolder?.deleteOutputOnDownload ?? false);
|
||||
outputNameDirty.current = !!editFolder?.outputName;
|
||||
setShowAdvanced(!!editFolder);
|
||||
setNameError('');
|
||||
setAutomationError('');
|
||||
setSaveError(null);
|
||||
@@ -155,7 +156,6 @@ export function SmartFolderManagementModal({
|
||||
const trimmedName = name.trim();
|
||||
const isServerFolder = inputSource === 'server-folder';
|
||||
|
||||
// Validate server-folder compatibility before touching IDB
|
||||
let configJson: string | null = null;
|
||||
if (isServerFolder) {
|
||||
configJson = buildPipelineJson(automation, toolRegistry);
|
||||
@@ -172,7 +172,7 @@ export function SmartFolderManagementModal({
|
||||
const ttlHoursNum = isServerFolder && outputTtlHours !== 'forever' ? Number(outputTtlHours) : null;
|
||||
const folderData = {
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
description: '',
|
||||
icon,
|
||||
accentColor,
|
||||
automationId: automation.id,
|
||||
@@ -195,7 +195,6 @@ export function SmartFolderManagementModal({
|
||||
} else if (!hasOutputDirectory) {
|
||||
await folderDirectoryHandleStorage.remove(editFolder.id);
|
||||
}
|
||||
// Sync server watch folder
|
||||
if (isServerFolder && configJson) {
|
||||
if (wasServerFolder) {
|
||||
await updateServerFolder(editFolder.id, trimmedName, configJson, ttlHoursNum, deleteOutputOnDownload);
|
||||
@@ -203,7 +202,7 @@ export function SmartFolderManagementModal({
|
||||
await createServerFolder(editFolder.id, trimmedName, configJson, ttlHoursNum, deleteOutputOnDownload);
|
||||
}
|
||||
} else if (wasServerFolder && !isServerFolder) {
|
||||
await deleteServerFolder(editFolder.id).catch(() => {}); // best-effort
|
||||
await deleteServerFolder(editFolder.id).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const newFolder = await smartFolderStorage.createFolder(folderData);
|
||||
@@ -223,7 +222,7 @@ export function SmartFolderManagementModal({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [name, description, icon, accentColor, outputMode, outputName, outputNamePosition, outputDirName, maxRetries, retryDelayMinutes, inputSource, outputTtlHours, deleteOutputOnDownload, isEditMode, editFolder, toolRegistry, resetState, onSaved, onClose, t]);
|
||||
}, [name, icon, accentColor, outputMode, outputName, outputNamePosition, outputDirName, maxRetries, retryDelayMinutes, inputSource, outputTtlHours, deleteOutputOnDownload, isEditMode, editFolder, toolRegistry, resetState, onSaved, onClose, t]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedName = name.trim();
|
||||
@@ -285,19 +284,11 @@ export function SmartFolderManagementModal({
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '2rem',
|
||||
height: '2rem',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '0.25rem', borderRadius: 'var(--mantine-radius-sm)',
|
||||
color: 'var(--mantine-color-dimmed)', fontSize: '1.25rem',
|
||||
lineHeight: 1, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', width: '2rem', height: '2rem',
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
@@ -310,7 +301,7 @@ export function SmartFolderManagementModal({
|
||||
|
||||
{/* ── Left panel: folder config ── */}
|
||||
<div style={{
|
||||
width: '30rem',
|
||||
width: '28rem',
|
||||
flexShrink: 0,
|
||||
borderRight: '0.0625rem solid var(--border-subtle)',
|
||||
display: 'flex',
|
||||
@@ -318,12 +309,12 @@ export function SmartFolderManagementModal({
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '1.25rem 1.5rem' }}>
|
||||
<Stack gap="xl">
|
||||
<Stack gap="lg">
|
||||
|
||||
{/* Identity */}
|
||||
{/* ── Identity ── */}
|
||||
<div>
|
||||
<SectionLabel>Folder</SectionLabel>
|
||||
<Stack gap="sm">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
placeholder={t('smartFolders.modal.namePlaceholder', 'My Watch Folder')}
|
||||
@@ -342,6 +333,7 @@ export function SmartFolderManagementModal({
|
||||
/>
|
||||
<IconSelector value={icon} onChange={setIcon} size="sm" />
|
||||
</Group>
|
||||
|
||||
<ColorInput
|
||||
label={t('smartFolders.modal.color', 'Accent colour')}
|
||||
value={accentColor}
|
||||
@@ -354,24 +346,76 @@ export function SmartFolderManagementModal({
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
{/* ── Source & Output ── */}
|
||||
<div>
|
||||
<SectionLabel>Output</SectionLabel>
|
||||
<SectionLabel>Source & Output</SectionLabel>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Input source"
|
||||
value={inputSource}
|
||||
onChange={(v) => v && setInputSource(v as NonNullable<SmartFolder['inputSource']>)}
|
||||
data={[
|
||||
{ value: 'idb', label: 'Browser — drop files here' },
|
||||
{ value: 'server-folder', label: 'Server watch folder' },
|
||||
]}
|
||||
size="sm"
|
||||
comboboxProps={{ withinPortal: true, zIndex: 400 }}
|
||||
/>
|
||||
|
||||
{/* Server-specific options, visually indented under the select */}
|
||||
{inputSource === 'server-folder' && (
|
||||
<Box style={{
|
||||
marginLeft: '0.75rem',
|
||||
paddingLeft: '0.75rem',
|
||||
borderLeft: '2px solid var(--border-subtle)',
|
||||
}}>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Keep processed files on server"
|
||||
value={outputTtlHours}
|
||||
onChange={(v) => v && setOutputTtlHours(v)}
|
||||
data={[
|
||||
{ value: '1', label: '1 hour' },
|
||||
{ value: '6', label: '6 hours' },
|
||||
{ value: '24', label: '24 hours' },
|
||||
{ value: '168', label: '7 days' },
|
||||
{ value: '720', label: '30 days' },
|
||||
{ value: 'forever', label: 'Forever' },
|
||||
]}
|
||||
size="sm"
|
||||
comboboxProps={{ withinPortal: true, zIndex: 400 }}
|
||||
/>
|
||||
<Switch
|
||||
label="Delete from server after download"
|
||||
description="Output file is removed from the server after downloading"
|
||||
checked={deleteOutputOnDownload}
|
||||
onChange={(e) => setDeleteOutputOnDownload(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Local output folder */}
|
||||
<Box
|
||||
style={{
|
||||
padding: '0.625rem 0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
border: `0.0625rem solid ${outputDirName ? 'rgba(34,197,94,0.4)' : 'var(--border-subtle)'}`,
|
||||
backgroundColor: outputDirName ? 'rgba(34,197,94,0.06)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<FolderSpecialIcon style={{ fontSize: '1rem', color: outputDirName ? '#22c55e' : 'var(--mantine-color-dimmed)', flexShrink: 0 }} />
|
||||
<FolderSpecialIcon style={{
|
||||
fontSize: '1rem',
|
||||
color: outputDirName ? '#22c55e' : 'var(--mantine-color-dimmed)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={500}>Local output folder</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>{outputDirName ?? 'App storage only'}</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{outputDirName ?? 'Not set — outputs stay in app'}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -387,131 +431,119 @@ export function SmartFolderManagementModal({
|
||||
{outputDirName ? 'Change' : 'Choose'}
|
||||
</Button>
|
||||
{outputDirName && (
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { pendingDirHandle.current = null; setOutputDirName(null); }}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => { pendingDirHandle.current = null; setOutputDirName(null); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{!outputDirName && (
|
||||
<Switch
|
||||
label={t('smartFolders.modal.outputModeVersion', 'Replace original')}
|
||||
description={outputMode === 'new_version' ? 'Output replaces the input as a new version' : 'Output saved as a separate new file'}
|
||||
checked={outputMode === 'new_version'}
|
||||
onChange={(e) => setOutputMode(e.currentTarget.checked ? 'new_version' : 'new_file')}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
{/* ── Advanced (collapsible) ── */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(v => !v)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.35rem',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '0.25rem 0', width: '100%',
|
||||
color: 'var(--tool-subcategory-text-color)',
|
||||
fontSize: '0.7rem', fontWeight: 600,
|
||||
letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'inline-block', fontSize: '0.55rem',
|
||||
transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 160ms ease',
|
||||
}}>
|
||||
▶
|
||||
</span>
|
||||
Advanced
|
||||
</button>
|
||||
|
||||
<Box style={{ opacity: outputMode === 'new_version' ? 0.4 : 1, pointerEvents: outputMode === 'new_version' ? 'none' : 'auto' }}>
|
||||
<Group gap="xs" align="flex-end">
|
||||
{outputNamePosition === 'auto-number' ? (
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={500} mb={4}>Auto-number</Text>
|
||||
<Text size="xs" c="dimmed">e.g. document.pdf → document (1).pdf</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<TextInput
|
||||
label={outputNamePosition === 'suffix' ? 'Filename suffix' : 'Filename prefix'}
|
||||
value={outputName}
|
||||
onChange={(e) => { outputNameDirty.current = true; setOutputName(e.currentTarget.value); }}
|
||||
maxLength={100}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
<Collapse in={showAdvanced} transitionDuration={180}>
|
||||
<Stack gap="sm" mt="sm">
|
||||
|
||||
{/* Replace original — only meaningful for browser mode */}
|
||||
{inputSource !== 'server-folder' && (
|
||||
<Switch
|
||||
label="Replace original file"
|
||||
description={outputMode === 'new_version'
|
||||
? 'Output replaces input as a new version'
|
||||
: 'Output saved as a separate new file'}
|
||||
checked={outputMode === 'new_version'}
|
||||
onChange={(e) => setOutputMode(e.currentTarget.checked ? 'new_version' : 'new_file')}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filename prefix / suffix */}
|
||||
<Box style={{
|
||||
opacity: outputMode === 'new_version' ? 0.4 : 1,
|
||||
pointerEvents: outputMode === 'new_version' ? 'none' : 'auto',
|
||||
}}>
|
||||
<Group gap="xs" align="flex-end">
|
||||
{outputNamePosition === 'auto-number' ? (
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={500} mb={4}>Auto-number</Text>
|
||||
<Text size="xs" c="dimmed">e.g. document.pdf → document (1).pdf</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<TextInput
|
||||
label={outputNamePosition === 'suffix' ? 'Filename suffix' : 'Filename prefix'}
|
||||
value={outputName}
|
||||
onChange={(e) => { outputNameDirty.current = true; setOutputName(e.currentTarget.value); }}
|
||||
maxLength={100}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
value={outputNamePosition}
|
||||
onChange={(v) => v && setOutputNamePosition(v as 'prefix' | 'suffix' | 'auto-number')}
|
||||
data={[
|
||||
{ value: 'prefix', label: 'Prefix' },
|
||||
{ value: 'suffix', label: 'Suffix' },
|
||||
{ value: 'auto-number', label: 'Auto-number' },
|
||||
]}
|
||||
style={{ width: '8rem', flexShrink: 0 }}
|
||||
mb={4}
|
||||
comboboxProps={{ withinPortal: true, zIndex: 400 }}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
value={outputNamePosition}
|
||||
onChange={(v) => v && setOutputNamePosition(v as 'prefix' | 'suffix' | 'auto-number')}
|
||||
data={[
|
||||
{ value: 'prefix', label: 'Prefix' },
|
||||
{ value: 'suffix', label: 'Suffix' },
|
||||
{ value: 'auto-number', label: 'Auto-number' },
|
||||
]}
|
||||
style={{ width: '8rem', flexShrink: 0 }}
|
||||
mb={4}
|
||||
comboboxProps={{ withinPortal: true, zIndex: 400 }}
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Retry settings */}
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput
|
||||
label="Max auto retries"
|
||||
value={maxRetries}
|
||||
onChange={(v) => setMaxRetries(typeof v === 'number' ? Math.max(0, Math.min(10, v)) : 0)}
|
||||
min={0}
|
||||
max={10}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Retry interval (min)"
|
||||
value={retryDelayMinutes}
|
||||
onChange={(v) => setRetryDelayMinutes(typeof v === 'number' ? Math.max(1, Math.min(60, v)) : 5)}
|
||||
min={1}
|
||||
max={60}
|
||||
size="sm"
|
||||
disabled={maxRetries === 0}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Auto-retry */}
|
||||
<div>
|
||||
<SectionLabel>Auto-retry</SectionLabel>
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput
|
||||
label="Max auto retries"
|
||||
value={maxRetries}
|
||||
onChange={(v) => setMaxRetries(typeof v === 'number' ? Math.max(0, Math.min(10, v)) : 0)}
|
||||
min={0}
|
||||
max={10}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Retry interval (minutes)"
|
||||
value={retryDelayMinutes}
|
||||
onChange={(v) => setRetryDelayMinutes(typeof v === 'number' ? Math.max(1, Math.min(60, v)) : 5)}
|
||||
min={1}
|
||||
max={60}
|
||||
size="sm"
|
||||
disabled={maxRetries === 0}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Data Flow */}
|
||||
<div>
|
||||
<SectionLabel>Data Flow</SectionLabel>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Input source"
|
||||
value={inputSource}
|
||||
onChange={(v) => v && setInputSource(v as NonNullable<SmartFolder['inputSource']>)}
|
||||
data={[
|
||||
{ value: 'idb', label: 'Browser storage (default)' },
|
||||
{ value: 'server-folder', label: 'Server watch folder' },
|
||||
]}
|
||||
size="sm"
|
||||
description={
|
||||
inputSource === 'server-folder'
|
||||
? 'Files are placed in a server directory and processed on a 60 s scan cycle. All automation steps must run server-side.'
|
||||
: 'Files stay in the browser and are processed locally.'
|
||||
}
|
||||
comboboxProps={{ withinPortal: true, zIndex: 400 }}
|
||||
/>
|
||||
|
||||
{inputSource === 'server-folder' && (
|
||||
<>
|
||||
<Select
|
||||
label="Keep output files on server"
|
||||
value={outputTtlHours}
|
||||
onChange={(v) => v && setOutputTtlHours(v)}
|
||||
data={[
|
||||
{ value: '1', label: '1 hour' },
|
||||
{ value: '6', label: '6 hours' },
|
||||
{ value: '24', label: '24 hours' },
|
||||
{ value: '168', label: '7 days' },
|
||||
{ value: '720', label: '30 days' },
|
||||
{ value: 'forever', label: 'Forever' },
|
||||
]}
|
||||
size="sm"
|
||||
comboboxProps={{ withinPortal: true, zIndex: 400 }}
|
||||
/>
|
||||
<Switch
|
||||
label="Delete from server after local export"
|
||||
description="Output file is removed from the server after it has been written to the configured local output folder."
|
||||
checked={deleteOutputOnDownload}
|
||||
onChange={(e) => setDeleteOutputOnDownload(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
disabled={outputDirName === null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
</Stack>
|
||||
@@ -529,13 +561,15 @@ export function SmartFolderManagementModal({
|
||||
{t('cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!name.trim()}>
|
||||
{isEditMode ? t('smartFolders.modal.saveChanges', 'Save changes') : t('smartFolders.modal.createFolder', 'Create folder')}
|
||||
{isEditMode
|
||||
? t('smartFolders.modal.saveChanges', 'Save changes')
|
||||
: t('smartFolders.modal.createFolder', 'Create folder')}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right panel: automation / tool steps ── */}
|
||||
{/* ── Right panel: automation steps ── */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '1rem 1.5rem 0.5rem', flexShrink: 0 }}>
|
||||
<SectionLabel>Steps</SectionLabel>
|
||||
@@ -547,7 +581,10 @@ export function SmartFolderManagementModal({
|
||||
existingAutomation={existingAutomation ?? undefined}
|
||||
onBack={handleClose}
|
||||
onComplete={handleAutomationComplete}
|
||||
onSaveFailed={() => { setSaving(false); setAutomationError(t('smartFolders.modal.automationRequired', 'Add at least one configured step before saving.')); }}
|
||||
onSaveFailed={() => {
|
||||
setSaving(false);
|
||||
setAutomationError(t('smartFolders.modal.automationRequired', 'Add at least one configured step before saving.'));
|
||||
}}
|
||||
toolRegistry={toolRegistry}
|
||||
hideMetadata
|
||||
nameOverride={name.trim() || t('smartFolders.modal.automationNameFallback', 'Watch Folder Automation')}
|
||||
|
||||
@@ -164,6 +164,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
const [inputFiles, setInputFiles] = useState<StirlingFile[]>([]);
|
||||
const [previewFileId, setPreviewFileId] = useState<FileId | null>(null);
|
||||
const [previewFileName, setPreviewFileName] = useState('');
|
||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||
|
||||
// Filter / sort state
|
||||
const [activitySearch, setActivitySearch] = useState('');
|
||||
@@ -318,16 +319,28 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
}, []);
|
||||
|
||||
/** Download a server-folder output file on demand (it is not in IDB). */
|
||||
const handleServerOutputDownload = useCallback(async (filename: string) => {
|
||||
const handleServerOutputDownload = useCallback(async (serverFilename: string, displayName?: string) => {
|
||||
if (!folderId) return;
|
||||
try {
|
||||
const file = await downloadServerFolderOutput(folderId, filename);
|
||||
await handleDownload(file, filename);
|
||||
const file = await downloadServerFolderOutput(folderId, serverFilename);
|
||||
await handleDownload(file, displayName ?? serverFilename);
|
||||
} catch {
|
||||
// Surface as a no-op — the file may have expired from the server
|
||||
}
|
||||
}, [folderId, handleDownload]);
|
||||
|
||||
/** Preview a server-folder output file by fetching it on demand. */
|
||||
const handleServerOutputPreview = useCallback(async (serverFilename: string, displayName?: string) => {
|
||||
if (!folderId) return;
|
||||
try {
|
||||
const file = await downloadServerFolderOutput(folderId, serverFilename);
|
||||
setPreviewFileName(displayName ?? serverFilename);
|
||||
setPreviewFile(file);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}, [folderId]);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: null });
|
||||
actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
|
||||
@@ -1032,7 +1045,10 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); void handleDownload(primaryFile, primaryFile.name); }} title="Export"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
|
||||
)}
|
||||
{!isExpanded && isServerFolder && hasPrimaryServerOutput && (
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); void handleServerOutputDownload(serverOutputNames[0]); }} title="Export from server"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); const fn = serverOutputNames[0]; const ext = fn.includes('.') ? fn.substring(fn.lastIndexOf('.')) : ''; const base = filename.includes('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; void handleServerOutputPreview(fn, base + ext); }} title="Preview"><VisibilityIcon style={{ fontSize: '0.875rem' }} /></button>
|
||||
)}
|
||||
{!isExpanded && isServerFolder && hasPrimaryServerOutput && (
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); const fn = serverOutputNames[0]; const ext = fn.includes('.') ? fn.substring(fn.lastIndexOf('.')) : ''; const base = filename.includes('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; void handleServerOutputDownload(fn, base + ext); }} title="Export from server"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
|
||||
)}
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); void handleDeleteOne(fileId); }} title="Delete"><DeleteOutlineIcon style={{ fontSize: '0.875rem' }} /></button>
|
||||
</Box>
|
||||
@@ -1067,7 +1083,8 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
<Text style={{ fontSize: '0.625rem', letterSpacing: '0.04em', color: '#22c55e', textTransform: 'uppercase', flexShrink: 0 }}>out</Text>
|
||||
<Text size="xs" style={{ flex: 1, minWidth: 0 }} lineClamp={1}>{fname}</Text>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>on server</Text>
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); void handleServerOutputDownload(fname); }} title="Download from server"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); const ext = fname.includes('.') ? fname.substring(fname.lastIndexOf('.')) : ''; const base = filename.includes('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; void handleServerOutputPreview(fname, base + ext); }} title="Preview"><VisibilityIcon style={{ fontSize: '0.875rem' }} /></button>
|
||||
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); const ext = fname.includes('.') ? fname.substring(fname.lastIndexOf('.')) : ''; const base = filename.includes('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; void handleServerOutputDownload(fname, base + ext); }} title="Download from server"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
|
||||
</Box>
|
||||
))}
|
||||
{/* Error detail + retry */}
|
||||
@@ -1284,8 +1301,9 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
|
||||
<FilePreviewModal
|
||||
fileId={previewFileId}
|
||||
file={previewFile}
|
||||
fileName={previewFileName}
|
||||
onClose={() => setPreviewFileId(null)}
|
||||
onClose={() => { setPreviewFileId(null); setPreviewFile(null); }}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
|
||||
@@ -25,10 +25,12 @@ import {
|
||||
submitBackendJob,
|
||||
getBackendJobStatus,
|
||||
getBackendJobResult,
|
||||
buildPipelineJson,
|
||||
} from '@app/utils/automationExecutor';
|
||||
import {
|
||||
uploadFileToServerFolder,
|
||||
updateServerFolderSession,
|
||||
createServerFolder,
|
||||
listServerFolderOutput,
|
||||
downloadServerFolderOutput,
|
||||
deleteServerFolderOutput,
|
||||
@@ -349,7 +351,11 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
|
||||
if (dirHandle) {
|
||||
const hasPermission = await folderDirectoryHandleStorage.ensurePermission(dirHandle);
|
||||
if (hasPermission) {
|
||||
await folderDirectoryHandleStorage.writeFile(dirHandle, outputFilename, resultFile);
|
||||
const origName = meta?.name ?? outputFilename;
|
||||
const outExt = outputFilename.includes('.') ? outputFilename.substring(outputFilename.lastIndexOf('.')) : '';
|
||||
const origBase = origName.includes('.') ? origName.substring(0, origName.lastIndexOf('.')) : origName;
|
||||
const displayName = origBase + outExt;
|
||||
await folderDirectoryHandleStorage.writeFile(dirHandle, displayName, resultFile);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -515,7 +521,9 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
|
||||
if (dirHandle) {
|
||||
const hasPermission = await folderDirectoryHandleStorage.ensurePermission(dirHandle);
|
||||
if (hasPermission) {
|
||||
await folderDirectoryHandleStorage.writeFile(dirHandle, outputFile.filename, resultFile);
|
||||
const outExt = outputFile.filename.includes('.') ? outputFile.filename.substring(outputFile.filename.lastIndexOf('.')) : '';
|
||||
const origBase = inputFile.name.includes('.') ? inputFile.name.substring(0, inputFile.name.lastIndexOf('.')) : inputFile.name;
|
||||
await folderDirectoryHandleStorage.writeFile(dirHandle, origBase + outExt, resultFile);
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
@@ -558,7 +566,11 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
|
||||
|
||||
// Server-folder input — upload to watch folder, trigger immediate processing via SSE
|
||||
if (isServerFolderInput(folder)) {
|
||||
await uploadFileToServerFolder(folder.id, inputFileId, file);
|
||||
// Load from IDB to guarantee we have the full file bytes.
|
||||
// The `file` parameter may be a stale drag-event reference whose data
|
||||
// is no longer readable after the async resolveInputFile call completed.
|
||||
const uploadFile = await fileStorage.getStirlingFile(inputFileId as FileId) ?? file;
|
||||
await uploadFileToServerFolder(folder.id, inputFileId, uploadFile);
|
||||
await folderStorage.updateFileMetadata(folder.id, inputFileId, {
|
||||
pendingOnServerFolder: true,
|
||||
});
|
||||
@@ -638,11 +650,35 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
|
||||
if (!isServerFolderInput(folder)) continue;
|
||||
try {
|
||||
await updateServerFolderSession(folder.id);
|
||||
} catch {
|
||||
// Best-effort — server folder may not exist yet or server may be down
|
||||
} catch (err: any) {
|
||||
// 404 means the server directory was never provisioned (e.g. backend wasn't running
|
||||
// when the folder was created). Re-provision it now using the stored automation.
|
||||
if (err?.response?.status === 404) {
|
||||
try {
|
||||
const automation = await automationStorage.getAutomation(folder.automationId);
|
||||
if (!automation) {
|
||||
console.warn(`[watch-folders] Cannot re-provision ${folder.id}: automation ${folder.automationId} not found in IDB`);
|
||||
} else {
|
||||
const configJson = buildPipelineJson(automation, toolRegistry);
|
||||
if (!configJson) {
|
||||
console.warn(`[watch-folders] Cannot re-provision ${folder.id}: automation has browser-only steps`);
|
||||
} else {
|
||||
await createServerFolder(
|
||||
folder.id, folder.name, configJson,
|
||||
folder.outputTtlHours ?? null, folder.deleteOutputOnDownload ?? false
|
||||
);
|
||||
console.info(`[watch-folders] Re-provisioned server folder ${folder.id}`);
|
||||
}
|
||||
}
|
||||
} catch (reprovisionErr) {
|
||||
console.warn(`[watch-folders] Re-provision failed for ${folder.id}:`, reprovisionErr);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[watch-folders] updateSession failed for ${folder.id}:`, err?.response?.status, err?.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [toolRegistry]);
|
||||
|
||||
// ── Lifecycle effects ──────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user