mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-11-12 10:05:24 +08:00
chore: artifacts cleanup (#33)
* artifacts cleanup * cargo build * cargo
This commit is contained in:
Generated
+1
-1
@@ -917,7 +917,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openresearch-cli"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openresearch-cli"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
edition = "2021"
|
||||
description = "OpenResearch CLI (orx) — Rust port"
|
||||
repository = "https://github.com/alphaXiv/openresearch-cli"
|
||||
|
||||
+63
-88
@@ -1,8 +1,8 @@
|
||||
//! Per-project artifacts directory — a plain folder on the user's machine
|
||||
//! (`<data dir>/artifacts/<project slug>/`). The filesystem is the source of
|
||||
//! truth: no registry, no upload step. Subfolders with a top-level `report.md`
|
||||
//! render as reports in the dashboard's Artifacts tab; everything else is
|
||||
//! browsable files.
|
||||
//! truth: no registry, no upload step. The dashboard's Artifacts tab is an
|
||||
//! explorer over this folder; a folder with a top-level `report.md` is still
|
||||
//! just a folder, it only additionally renders as a report.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -68,23 +68,23 @@ pub fn content_type_for_path(path: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A subfolder with a top-level `report.md`, rendered as a report.
|
||||
/// One node of the artifacts tree: a file or a directory with its children.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtifactReport {
|
||||
/// Folder name — the report's id and path prefix for its files.
|
||||
pub struct ArtifactEntry {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub modified_at: i64,
|
||||
}
|
||||
|
||||
/// Any other file in the artifacts dir, by dir-relative path.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtifactFile {
|
||||
/// Dir-relative, `/`-joined — the id for file/report/delete endpoints.
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
/// 0 for directories.
|
||||
pub size: u64,
|
||||
pub modified_at: i64,
|
||||
/// Set when this dir holds a top-level `report.md` — the UI offers a
|
||||
/// rendered-report view on top of the normal folder row.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub report_title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub children: Vec<ArtifactEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -93,8 +93,7 @@ pub struct ArtifactsListing {
|
||||
/// Absolute path of the artifacts dir, shown in the UI so the user can
|
||||
/// drop files in.
|
||||
pub dir: String,
|
||||
pub reports: Vec<ArtifactReport>,
|
||||
pub files: Vec<ArtifactFile>,
|
||||
pub entries: Vec<ArtifactEntry>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
@@ -131,102 +130,78 @@ fn report_title(md_path: &Path, fallback: &str) -> String {
|
||||
.unwrap_or_else(|| fallback.to_string())
|
||||
}
|
||||
|
||||
/// Recursively collect files under `dir` as `/`-joined paths relative to
|
||||
/// `base`, up to `MAX_ENTRIES`. Returns true when it hit the cap.
|
||||
fn collect_files(base: &Path, dir: &Path, out: &mut Vec<ArtifactFile>) -> bool {
|
||||
/// Recursively build the tree under `dir`, counting nodes against
|
||||
/// `MAX_ENTRIES`. Returns (children, hit_cap).
|
||||
fn collect_tree(dir: &Path, rel_prefix: &str, seen: &mut usize) -> (Vec<ArtifactEntry>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut truncated = false;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
return (out, false);
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if out.len() >= MAX_ENTRIES {
|
||||
return true;
|
||||
if *seen >= MAX_ENTRIES {
|
||||
return (out, true);
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if is_ignored(&name) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let Ok(md) = entry.metadata() else { continue };
|
||||
*seen += 1;
|
||||
let rel = if rel_prefix.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{rel_prefix}/{name}")
|
||||
};
|
||||
if md.is_dir() {
|
||||
if collect_files(base, &path, out) {
|
||||
return true;
|
||||
}
|
||||
let report_md = entry.path().join("report.md");
|
||||
let report_title = report_md.is_file().then(|| report_title(&report_md, &name));
|
||||
let (children, hit) = collect_tree(&entry.path(), &rel, seen);
|
||||
truncated |= hit;
|
||||
out.push(ArtifactEntry {
|
||||
name,
|
||||
path: rel,
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
modified_at: mtime_ms(&md),
|
||||
report_title,
|
||||
children,
|
||||
});
|
||||
} else if md.is_file() {
|
||||
if let Ok(rel) = path.strip_prefix(base) {
|
||||
let rel = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
if !rel.is_empty() {
|
||||
out.push(ArtifactFile {
|
||||
path: rel,
|
||||
size: md.len(),
|
||||
modified_at: mtime_ms(&md),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Scan the artifacts dir (creating it if missing): report folders first,
|
||||
/// then every other file.
|
||||
pub fn list(project: &LocalProject) -> Result<ArtifactsListing> {
|
||||
let dir = ensure_dir(project)?;
|
||||
let mut reports = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut truncated = false;
|
||||
|
||||
let entries =
|
||||
std::fs::read_dir(&dir).map_err(|e| anyhow!("Could not read {}: {}", dir.display(), e))?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if is_ignored(&name) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let Ok(md) = entry.metadata() else { continue };
|
||||
if md.is_dir() {
|
||||
let report_md = path.join("report.md");
|
||||
if report_md.is_file() {
|
||||
let modified = std::fs::metadata(&report_md)
|
||||
.map(|m| mtime_ms(&m))
|
||||
.unwrap_or(0);
|
||||
reports.push(ArtifactReport {
|
||||
title: report_title(&report_md, &name),
|
||||
name,
|
||||
modified_at: modified,
|
||||
});
|
||||
} else {
|
||||
truncated |= collect_files(&dir, &path, &mut files);
|
||||
}
|
||||
} else if md.is_file() {
|
||||
files.push(ArtifactFile {
|
||||
path: name,
|
||||
out.push(ArtifactEntry {
|
||||
name,
|
||||
path: rel,
|
||||
is_dir: false,
|
||||
size: md.len(),
|
||||
modified_at: mtime_ms(&md),
|
||||
report_title: None,
|
||||
children: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Dirs first, then files, each alphabetical — stable explorer order.
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
|
||||
(out, truncated)
|
||||
}
|
||||
|
||||
reports.sort_by_key(|r| std::cmp::Reverse(r.modified_at));
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
/// Scan the artifacts dir (creating it if missing) into a file tree.
|
||||
pub fn list(project: &LocalProject) -> Result<ArtifactsListing> {
|
||||
let dir = ensure_dir(project)?;
|
||||
let mut seen = 0;
|
||||
let (entries, truncated) = collect_tree(&dir, "", &mut seen);
|
||||
Ok(ArtifactsListing {
|
||||
dir: dir.to_string_lossy().into_owned(),
|
||||
reports,
|
||||
files,
|
||||
entries,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// A report folder's `report.md` body.
|
||||
pub fn read_report_markdown(project: &LocalProject, report_name: &str) -> Result<String> {
|
||||
if !is_safe_artifact_path(report_name) || report_name.contains('/') {
|
||||
return Err(anyhow!("invalid report name: {report_name}"));
|
||||
/// A report folder's `report.md` body, by dir-relative folder path.
|
||||
pub fn read_report_markdown(project: &LocalProject, folder: &str) -> Result<String> {
|
||||
if !is_safe_artifact_path(folder) {
|
||||
return Err(anyhow!("invalid report path: {folder}"));
|
||||
}
|
||||
let path = artifacts_dir(project).join(report_name).join("report.md");
|
||||
let path = artifacts_dir(project).join(folder).join("report.md");
|
||||
std::fs::read_to_string(&path).map_err(|e| anyhow!("Could not read {}: {}", path.display(), e))
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -8,8 +8,8 @@
|
||||
html { background: #ffffff; }
|
||||
@media (prefers-color-scheme: dark) { html { background: #0e0c0c; } }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-DV0y0l2f.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dm6rH5Gf.css">
|
||||
<script type="module" crossorigin src="/assets/index-CMAbKIpS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bz7UoZ7k.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+2
-4
@@ -305,10 +305,8 @@ export default function App() {
|
||||
onClick={() => setRightTab("artifacts")}
|
||||
>
|
||||
Artifacts
|
||||
{artifacts && artifacts.reports.length + artifacts.files.length > 0 && (
|
||||
<span className="tab-count">
|
||||
{artifacts.reports.length + artifacts.files.length}
|
||||
</span>
|
||||
{artifacts && artifacts.entries.length > 0 && (
|
||||
<span className="tab-count">{artifacts.entries.length}</span>
|
||||
)}
|
||||
</button>
|
||||
{expTabs.map((t) => {
|
||||
|
||||
+9
-10
@@ -283,25 +283,24 @@ export interface SshPreflight {
|
||||
export const sshPreflight = (host: string) =>
|
||||
post<SshPreflight>("/api/settings/ssh/preflight", { host });
|
||||
|
||||
/** A subfolder of the artifacts dir with a top-level report.md. */
|
||||
export interface ArtifactReport {
|
||||
/** One node of the artifacts tree: a file, or a directory with children. */
|
||||
export interface ArtifactEntry {
|
||||
name: string;
|
||||
title: string;
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
/** Any other file in the artifacts dir, by dir-relative path. */
|
||||
export interface ArtifactFile {
|
||||
/** Dir-relative `/`-joined path — the id for file/report/delete endpoints. */
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
/** 0 for directories. */
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
/** Set when the dir holds a top-level report.md — renders as a report. */
|
||||
reportTitle?: string;
|
||||
children?: ArtifactEntry[];
|
||||
}
|
||||
|
||||
/** Listing of the project's on-disk artifacts directory. */
|
||||
export interface Artifacts {
|
||||
dir: string;
|
||||
reports: ArtifactReport[];
|
||||
files: ArtifactFile[];
|
||||
entries: ArtifactEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
|
||||
+185
-111
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
@@ -15,8 +17,7 @@ import {
|
||||
artifactFileUrl,
|
||||
deleteArtifact,
|
||||
getArtifactReport,
|
||||
type ArtifactFile,
|
||||
type ArtifactReport,
|
||||
type ArtifactEntry,
|
||||
type Artifacts,
|
||||
type Project,
|
||||
} from "../api";
|
||||
@@ -40,21 +41,33 @@ function fmtBytes(n: number): string {
|
||||
|
||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
||||
|
||||
/** Depth-first lookup of a tree entry by its dir-relative path. */
|
||||
function findEntry(entries: ArtifactEntry[], path: string): ArtifactEntry | null {
|
||||
for (const e of entries) {
|
||||
if (e.path === path) return e;
|
||||
if (e.isDir && path.startsWith(e.path + "/")) {
|
||||
const hit = findEntry(e.children ?? [], path);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Report markdown with report-relative image/link paths (`images/...`)
|
||||
* rewritten to the artifact file endpoint, scoped to the report's folder. */
|
||||
function ReportMd({
|
||||
projectId,
|
||||
reportName,
|
||||
folder,
|
||||
markdown,
|
||||
}: {
|
||||
projectId: string;
|
||||
reportName: string;
|
||||
folder: string;
|
||||
markdown: string;
|
||||
}) {
|
||||
const resolve = (src: string) =>
|
||||
isExternalSrc(src)
|
||||
? src
|
||||
: artifactFileUrl(projectId, `${reportName}/${src.replace(/^\.?\//, "")}`);
|
||||
: artifactFileUrl(projectId, `${folder}/${src.replace(/^\.?\//, "")}`);
|
||||
return (
|
||||
<div className="md report-md">
|
||||
<ReactMarkdown
|
||||
@@ -89,12 +102,12 @@ function ReportMd({
|
||||
|
||||
function ReportView({
|
||||
projectId,
|
||||
report,
|
||||
entry,
|
||||
onBack,
|
||||
onDelete,
|
||||
}: {
|
||||
projectId: string;
|
||||
report: ArtifactReport;
|
||||
entry: ArtifactEntry;
|
||||
onBack: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
@@ -102,25 +115,25 @@ function ReportView({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getArtifactReport(projectId, report.name)
|
||||
getArtifactReport(projectId, entry.path)
|
||||
.then((r) => setMarkdown(r.markdown))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)));
|
||||
}, [projectId, report.name, report.modifiedAt]);
|
||||
}, [projectId, entry.path, entry.modifiedAt]);
|
||||
|
||||
return (
|
||||
<div className="report-view">
|
||||
<div className="report-view-head">
|
||||
<button className="report-back" onClick={onBack}>
|
||||
<ArrowLeft size={13} /> Reports
|
||||
<ArrowLeft size={13} /> Artifacts
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="report-date">{new Date(report.modifiedAt).toLocaleString()}</span>
|
||||
<span className="report-date">{new Date(entry.modifiedAt).toLocaleString()}</span>
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Delete report folder"
|
||||
aria-label="Delete report folder"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete the "${report.name}" folder from the artifacts dir?`))
|
||||
if (window.confirm(`Delete the "${entry.path}" folder from the artifacts dir?`))
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
@@ -134,7 +147,7 @@ function ReportView({
|
||||
<span className="spinner" /> Loading report…
|
||||
</div>
|
||||
) : (
|
||||
<ReportMd projectId={projectId} reportName={report.name} markdown={markdown} />
|
||||
<ReportMd projectId={projectId} folder={entry.path} markdown={markdown} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -163,9 +176,119 @@ function DirPath({ dir }: { dir: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Right-pane Artifacts tab — a live view of the project's artifacts folder
|
||||
* on disk. Subfolders with a report.md render as reports; everything else
|
||||
* (figures, CSVs, whatever the user drops in) is listed as files. */
|
||||
function TreeRows({
|
||||
projectId,
|
||||
entries,
|
||||
depth,
|
||||
expanded,
|
||||
onToggle,
|
||||
onOpenReport,
|
||||
onDelete,
|
||||
}: {
|
||||
projectId: string;
|
||||
entries: ArtifactEntry[];
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onOpenReport: (path: string) => void;
|
||||
onDelete: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{entries.map((e) => {
|
||||
const indent = { paddingLeft: 12 + depth * 18 };
|
||||
const del = (
|
||||
<button
|
||||
className="icon-btn artifact-tree-del"
|
||||
title={`Delete ${e.path}`}
|
||||
aria-label={`Delete ${e.path}`}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
if (window.confirm(`Delete "${e.path}" from the artifacts dir?`)) onDelete(e.path);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (e.isDir) {
|
||||
const isReport = e.reportTitle !== undefined;
|
||||
const open = expanded.has(e.path);
|
||||
return (
|
||||
<div key={e.path}>
|
||||
<div
|
||||
className="artifact-tree-row clickable"
|
||||
style={indent}
|
||||
onClick={() => (isReport ? onOpenReport(e.path) : onToggle(e.path))}
|
||||
>
|
||||
<button
|
||||
className={`artifact-tree-chevron ${open ? "open" : ""}`}
|
||||
aria-label={open ? `Collapse ${e.name}` : `Expand ${e.name}`}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onToggle(e.path);
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={13} />
|
||||
</button>
|
||||
{isReport ? <FileText size={14} /> : <Folder size={14} />}
|
||||
<span className={`artifact-tree-name ${isReport ? "report" : ""}`}>
|
||||
{e.reportTitle || e.name}
|
||||
</span>
|
||||
{del}
|
||||
<span className="report-date">
|
||||
{new Date(e.modifiedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{open && (
|
||||
<TreeRows
|
||||
projectId={projectId}
|
||||
entries={e.children ?? []}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onOpenReport={onOpenReport}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={e.path} className="artifact-tree-row" style={indent}>
|
||||
<span className="artifact-tree-chevron spacer" />
|
||||
<a
|
||||
className="artifact-file-link"
|
||||
href={artifactFileUrl(projectId, e.path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={e.path}
|
||||
>
|
||||
{IMAGE_RE.test(e.name) ? (
|
||||
<img
|
||||
className="artifact-thumb"
|
||||
src={artifactFileUrl(projectId, e.path)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<File size={14} />
|
||||
)}
|
||||
<span className="artifact-tree-name">{e.name}</span>
|
||||
</a>
|
||||
{del}
|
||||
<span className="report-date">{fmtBytes(e.size)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right-pane Artifacts tab — an explorer over the project's artifacts folder
|
||||
* on disk. Every entry is an artifact; a folder with a top-level report.md
|
||||
* additionally opens as a rendered report. */
|
||||
export function ArtifactsTab({
|
||||
project,
|
||||
artifacts,
|
||||
@@ -175,16 +298,24 @@ export function ArtifactsTab({
|
||||
artifacts: Artifacts | null;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [openName, setOpenName] = useState<string | null>(null);
|
||||
const open = openName
|
||||
? (artifacts?.reports.find((r) => r.name === openName) ?? null)
|
||||
: null;
|
||||
const [openPath, setOpenPath] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const openEntry = openPath && artifacts ? findEntry(artifacts.entries, openPath) : null;
|
||||
|
||||
// The open report vanished from disk (deleted externally) — go back.
|
||||
useEffect(() => {
|
||||
if (openName && artifacts && !artifacts.reports.some((r) => r.name === openName))
|
||||
setOpenName(null);
|
||||
}, [openName, artifacts]);
|
||||
if (openPath && artifacts && !findEntry(artifacts.entries, openPath)?.reportTitle)
|
||||
setOpenPath(null);
|
||||
}, [openPath, artifacts]);
|
||||
|
||||
const toggle = (path: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
|
||||
const remove = (path: string) => {
|
||||
void deleteArtifact(project.id, path)
|
||||
@@ -192,15 +323,15 @@ export function ArtifactsTab({
|
||||
.finally(onChanged);
|
||||
};
|
||||
|
||||
if (open) {
|
||||
if (openEntry?.reportTitle) {
|
||||
return (
|
||||
<ReportView
|
||||
projectId={project.id}
|
||||
report={open}
|
||||
onBack={() => setOpenName(null)}
|
||||
entry={openEntry}
|
||||
onBack={() => setOpenPath(null)}
|
||||
onDelete={() => {
|
||||
remove(open.name);
|
||||
setOpenName(null);
|
||||
remove(openEntry.path);
|
||||
setOpenPath(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -217,7 +348,6 @@ export function ArtifactsTab({
|
||||
}
|
||||
|
||||
const repoUrl = `https://github.com/${project.githubOwner}/${project.githubRepo}`;
|
||||
const empty = artifacts.reports.length === 0 && artifacts.files.length === 0;
|
||||
return (
|
||||
<div className="artifacts">
|
||||
<section>
|
||||
@@ -232,93 +362,37 @@ export function ArtifactsTab({
|
||||
|
||||
<section>
|
||||
<h3 className="artifacts-heading">
|
||||
<FolderOpen size={13} /> Local folder
|
||||
<FolderOpen size={13} /> Artifacts
|
||||
</h3>
|
||||
<DirPath dir={artifacts.dir} />
|
||||
<p className="artifacts-hint">
|
||||
Everything in this folder shows up here — the agent writes reports and figures into
|
||||
it, and you can drop in your own files.
|
||||
An explorer over this folder — the agent writes reports and figures into it, and you
|
||||
can drop in your own files.
|
||||
</p>
|
||||
{artifacts.entries.length === 0 ? (
|
||||
<p className="artifacts-empty">
|
||||
Nothing here yet. Ask the agent for a write-up of its findings — it saves report
|
||||
folders (<code>report.md</code> + images) into the folder above.
|
||||
</p>
|
||||
) : (
|
||||
<div className="artifact-tree">
|
||||
<TreeRows
|
||||
projectId={project.id}
|
||||
entries={artifacts.entries}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
onToggle={toggle}
|
||||
onOpenReport={setOpenPath}
|
||||
onDelete={remove}
|
||||
/>
|
||||
{artifacts.truncated && (
|
||||
<p className="artifacts-hint" style={{ padding: "6px 12px" }}>
|
||||
Listing truncated — the folder has more files.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{empty ? (
|
||||
<p className="artifacts-empty">
|
||||
Nothing here yet. Ask the agent for a write-up of its findings — it saves report
|
||||
folders (<code>report.md</code> + images) into the folder above.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{artifacts.reports.length > 0 && (
|
||||
<section>
|
||||
<h3 className="artifacts-heading">
|
||||
<FileText size={13} /> Reports
|
||||
<span className="artifacts-count">{artifacts.reports.length}</span>
|
||||
</h3>
|
||||
<ul className="report-list">
|
||||
{artifacts.reports.map((r) => (
|
||||
<li key={r.name}>
|
||||
<button className="report-row" onClick={() => setOpenName(r.name)}>
|
||||
<span className="report-row-title">{r.title}</span>
|
||||
<span className="report-date">
|
||||
{new Date(r.modifiedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{artifacts.files.length > 0 && (
|
||||
<section>
|
||||
<h3 className="artifacts-heading">
|
||||
<File size={13} /> Files
|
||||
<span className="artifacts-count">{artifacts.files.length}</span>
|
||||
</h3>
|
||||
<ul className="report-list">
|
||||
{artifacts.files.map((f: ArtifactFile) => (
|
||||
<li key={f.path}>
|
||||
<div className="report-row artifact-file-row">
|
||||
<a
|
||||
className="artifact-file-link"
|
||||
href={artifactFileUrl(project.id, f.path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={f.path}
|
||||
>
|
||||
{IMAGE_RE.test(f.path) && (
|
||||
<img
|
||||
className="artifact-thumb"
|
||||
src={artifactFileUrl(project.id, f.path)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<span className="report-row-title">{f.path}</span>
|
||||
</a>
|
||||
<span className="report-date">{fmtBytes(f.size)}</span>
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Delete file"
|
||||
aria-label={`Delete ${f.path}`}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${f.path}" from the artifacts dir?`))
|
||||
remove(f.path);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{artifacts.truncated && (
|
||||
<p className="artifacts-hint">Listing truncated — the folder has more files.</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+39
-20
@@ -1882,9 +1882,6 @@ select:focus {
|
||||
font-size: 11.5px;
|
||||
color: var(--subtext);
|
||||
}
|
||||
.artifact-file-row {
|
||||
align-items: center;
|
||||
}
|
||||
.artifact-file-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -1894,7 +1891,7 @@ select:focus {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.artifact-file-link:hover .report-row-title {
|
||||
.artifact-file-link:hover .artifact-tree-name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.artifact-thumb {
|
||||
@@ -1915,34 +1912,56 @@ select:focus {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.report-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.artifact-tree {
|
||||
border: 1px solid var(--border);
|
||||
margin-top: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.report-list li + li {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.report-row {
|
||||
.artifact-tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.report-row:hover {
|
||||
.artifact-tree-row:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.report-row-title {
|
||||
font-weight: 600;
|
||||
.artifact-tree-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.artifact-tree-chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.artifact-tree-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.artifact-tree-chevron.spacer {
|
||||
visibility: hidden;
|
||||
}
|
||||
.artifact-tree-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.artifact-tree-name.report {
|
||||
font-weight: 600;
|
||||
}
|
||||
.artifact-tree-del {
|
||||
visibility: hidden;
|
||||
}
|
||||
.artifact-tree-row:hover .artifact-tree-del {
|
||||
visibility: visible;
|
||||
}
|
||||
.report-date {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
|
||||
Reference in New Issue
Block a user