Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d4935c1
fix: list large artifact datasets and explain the listing cap
devin-ai-integration[bot] Oct 22, 2026
01d9139
fix: recurse with original dir paths and stop descending at the cap
devin-ai-integration[bot] Oct 22, 2026
635ceb4
fix: only flag truncation when an entry is actually omitted
devin-ai-integration[bot] Oct 22, 2026
c8b70e8
Merge remote-tracking branch 'origin/main' into devin/1792687492-arti…
devin-ai-integration[bot] Oct 22, 2026
1419dfe
fix: tolerate artifact listings without limit from older hosts
devin-ai-integration[bot] Oct 22, 2026
755463e
Merge remote-tracking branch 'origin/main' into devin/1792687492-arti…
devin-ai-integration[bot] Oct 22, 2026
6322d23
Load capped artifact folders on demand
devin-ai-integration[bot] Oct 22, 2026
ce0d3fb
Poll opened partial artifact folders and retry failed loads
devin-ai-integration[bot] Oct 22, 2026
7b5bc34
Stop polling partial folders that are hidden or gone
devin-ai-integration[bot] Oct 22, 2026
002330b
Close failed partial folders so reopening retries
devin-ai-integration[bot] Oct 22, 2026
49b8360
Merge remote-tracking branch 'origin/main' into devin/1792687492-arti…
devin-ai-integration[bot] Oct 22, 2026
a7c62a7
Include truncation flags in the artifacts fingerprint
devin-ai-integration[bot] Oct 22, 2026
1b6a7fb
Merge remote-tracking branch 'origin/main' into devin/1792687492-arti…
devin-ai-integration[bot] Oct 23, 2026
7e9870f
Merge remote-tracking branch 'origin/main' into devin/1792687492-arti…
devin-ai-integration[bot] Oct 23, 2026
1913419
Merge remote-tracking branch 'origin/main' into devin/1792687492-arti…
devin-ai-integration[bot] Oct 23, 2026
bb1671d
Show the listing-limit notice for capped partial folders too
devin-ai-integration[bot] Oct 23, 2026
035a72c
Merge remote-tracking branch 'origin/main' into devin/1792687492-arti…
devin-ai-integration[bot] Oct 23, 2026
3d2dd84
Merge remote-tracking branch 'origin/main' into devin/1792687492-arti…
devin-ai-integration[bot] Oct 23, 2026
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
13 changes: 11 additions & 2 deletions src/commands/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4451,7 +4451,11 @@ async fn absolute_raw_file(

/// Listing of the project's artifacts dir — the filesystem is the source of
/// truth; this scans it fresh on every call (and creates it if missing).
async fn list_artifacts(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
async fn list_artifacts(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ArtifactListQuery>,
) -> ApiResult {
blocking_api(move || {
let _admission = state
.project_lifecycle
Expand All @@ -4461,7 +4465,7 @@ async fn list_artifacts(State(state): State<AppState>, Path(id): Path<String>) -
let project = store
.get_local_project(&id)?
.ok_or_else(|| not_found("project"))?;
let listing = local::files::list(&project)?;
let listing = local::files::list(&project, q.dir.as_deref())?;
Ok(Json(json!(listing)))
})
.await
Expand Down Expand Up @@ -4489,6 +4493,11 @@ async fn manage_artifact_file(
.await
}

#[derive(Deserialize)]
struct ArtifactListQuery {
dir: Option<String>,
}

#[derive(Deserialize)]
struct ArtifactPathQuery {
path: String,
Expand Down
127 changes: 90 additions & 37 deletions src/local/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ use super::model::LocalProject;
const IGNORED: &[&str] = &[".DS_Store", "Thumbs.db"];

/// Listing cap — a runaway directory shouldn't stall the 2Hz event loop.
const MAX_ENTRIES: usize = 2000;
/// Sized so a typical extracted image dataset still lists in full.
const MAX_ENTRIES: usize = 10_000;

/// `<data dir>/files/`. The physical name stays stable for compatibility even
/// though the product surface is called Artifacts. A pre-v0.1.48 `artifacts/`
Expand Down Expand Up @@ -380,6 +381,9 @@ pub struct ArtifactEntry {
pub presentation: Option<FilePresentation>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub children: Vec<ArtifactEntry>,
/// Folder the capped listing didn't finish; list it on its own via `?dir=`.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub partial: bool,
}

#[derive(Debug, Clone, Serialize)]
Expand All @@ -390,6 +394,8 @@ pub struct ArtifactsListing {
pub dir: String,
pub entries: Vec<ArtifactEntry>,
pub truncated: bool,
/// `MAX_ENTRIES`, so the UI can explain the cap when `truncated`.
pub limit: usize,
}

fn mtime_ms(md: &std::fs::Metadata) -> i64 {
Expand All @@ -407,25 +413,29 @@ fn is_ignored(name: &str) -> bool {
/// Recursively build the tree under `dir`, counting nodes against
/// `MAX_ENTRIES`. Returns (children, hit_cap). Symlinks resolving outside
/// `canonical_base` are skipped — the serve endpoints would refuse them.
/// Siblings are listed before descending, so a capped tree still shows every
/// folder it reaches instead of dropping whole later folders.
fn collect_tree(
canonical_base: &Path,
dir: &Path,
rel_prefix: &str,
seen: &mut usize,
) -> (Vec<ArtifactEntry>, bool) {
let mut out = Vec::new();
let mut dirs = Vec::new();
let mut truncated = false;
let Ok(entries) = std::fs::read_dir(dir) else {
return (out, false);
};
for entry in entries.flatten() {
if *seen >= MAX_ENTRIES {
return (out, true);
}
let name = entry.file_name().to_string_lossy().into_owned();
if is_ignored(&name) {
continue;
}
if *seen >= MAX_ENTRIES {
truncated = true;
break;
}
let Some(md) = followed_metadata(canonical_base, &entry) else {
continue;
};
Expand All @@ -436,16 +446,16 @@ fn collect_tree(
format!("{rel_prefix}/{name}")
};
if md.is_dir() {
let (children, hit) = collect_tree(canonical_base, &entry.path(), &rel, seen);
truncated |= hit;
dirs.push((out.len(), entry.path()));
out.push(ArtifactEntry {
name,
path: rel,
is_dir: true,
size: 0,
modified_at: mtime_ms(&md),
presentation: None,
children,
children: Vec::new(),
partial: false,
});
} else if md.is_file() {
let presentation = presentation_for_path(&rel);
Expand All @@ -457,24 +467,43 @@ fn collect_tree(
modified_at: mtime_ms(&md),
presentation: Some(presentation),
children: Vec::new(),
partial: false,
});
}
}
for (i, path) in dirs {
if truncated {
out[i].partial = true;
continue;
}
let (children, hit) = collect_tree(canonical_base, &path, &out[i].path, seen);
out[i].children = children;
out[i].partial = hit;
truncated |= hit;
}
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
(out, truncated)
}

/// Scan the artifacts dir (creating it if missing) into a plain file tree.
pub fn list(project: &LocalProject) -> Result<ArtifactsListing> {
/// Scan the artifacts dir (creating it if missing) into a plain file tree,
/// or just the `sub` folder in it, with its own `MAX_ENTRIES` budget.
pub fn list(project: &LocalProject, sub: Option<&str>) -> Result<ArtifactsListing> {
let dir = ensure_dir(project)?;
let canonical = crate::paths::canonicalize(&dir)
.map_err(|e| anyhow!("Could not resolve {}: {}", dir.display(), e))?;
let mut seen = 0;
let (entries, truncated) = collect_tree(&canonical, &canonical, "", &mut seen);
let (start, prefix) = match sub {
Some(rel) => (resolve_contained(&canonical, rel)?, rel),
None => (canonical.clone(), ""),
};
if !start.is_dir() {
return Err(anyhow!("not a folder: {prefix}"));
}
let (entries, truncated) = collect_tree(&canonical, &start, prefix, &mut 0);
Ok(ArtifactsListing {
dir: dir.to_string_lossy().into_owned(),
entries,
truncated,
limit: MAX_ENTRIES,
})
}

Expand Down Expand Up @@ -512,40 +541,26 @@ pub fn delete_entry(project: &LocalProject, rel_path: &str) -> Result<()> {

/// Cheap change fingerprint (paths + sizes + mtimes) for the SSE diff loop.
/// A missing dir hashes to a stable value, so first creation is a change.
/// Built from `collect_tree`, so it tracks exactly what's listed.
pub fn fingerprint(project: &LocalProject) -> u64 {
let mut hasher = DefaultHasher::new();
if let Ok(canonical) = crate::paths::canonicalize(files_dir(project)) {
hash_dir(&canonical, &canonical, &mut hasher, &mut 0);
let (entries, truncated) = collect_tree(&canonical, &canonical, "", &mut 0);
hash_entries(&entries, &mut hasher);
truncated.hash(&mut hasher);
}
hasher.finish()
}

/// Hash the tree under `dir`, skipping (like `collect_tree`) symlinks that
/// resolve outside `base`, so the fingerprint tracks exactly what's listed.
fn hash_dir(base: &Path, dir: &Path, hasher: &mut DefaultHasher, seen: &mut usize) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if *seen >= MAX_ENTRIES {
return;
}
let name = entry.file_name().to_string_lossy().into_owned();
if is_ignored(&name) {
continue;
}
let Some(md) = followed_metadata(base, &entry) else {
continue;
};
*seen += 1;
if let Ok(rel) = entry.path().strip_prefix(base) {
rel.to_string_lossy().hash(hasher);
}
if md.is_dir() {
hash_dir(base, &entry.path(), hasher, seen);
fn hash_entries(entries: &[ArtifactEntry], hasher: &mut DefaultHasher) {
for e in entries {
e.path.hash(hasher);
if e.is_dir {
e.partial.hash(hasher);
hash_entries(&e.children, hasher);
} else {
md.len().hash(hasher);
mtime_ms(&md).hash(hasher);
e.size.hash(hasher);
e.modified_at.hash(hasher);
}
}
}
Expand Down Expand Up @@ -750,6 +765,44 @@ mod tests {
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn capped_listing_keeps_every_sibling_folder() {
let (root, base, _) = scratch();
for class in ["class_a", "class_b", "class_c"] {
std::fs::create_dir(base.join(class)).unwrap();
}
for i in 0..MAX_ENTRIES {
std::fs::write(base.join(format!("class_a/{i}.png")), "").unwrap();
}
let canonical = crate::paths::canonicalize(&base).unwrap();
let (entries, truncated) = collect_tree(&canonical, &canonical, "", &mut 0);
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["class_a", "class_b", "class_c"]);
assert_eq!(entries[0].children.len(), MAX_ENTRIES - 3);
assert!(truncated);
assert!(entries[0].partial);
// A partial folder listed on its own gets a fresh budget.
let (children, truncated) =
collect_tree(&canonical, &canonical.join("class_a"), "class_a", &mut 0);
assert_eq!(children.len(), MAX_ENTRIES);
assert!(children[0].path.starts_with("class_a/"));
assert!(!truncated);
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn listing_exactly_at_cap_is_not_truncated() {
let (root, base, _) = scratch();
for i in 0..MAX_ENTRIES {
std::fs::create_dir(base.join(format!("d{i}"))).unwrap();
}
let canonical = crate::paths::canonicalize(&base).unwrap();
let (entries, truncated) = collect_tree(&canonical, &canonical, "", &mut 0);
assert_eq!(entries.len(), MAX_ENTRIES);
assert!(!truncated);
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn content_types_cover_browser_media_families() {
for (path, expected_type, expected_presentation) in [
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

466 changes: 233 additions & 233 deletions ui/dist/assets/index-CE2Gq4kH.js → ui/dist/assets/index-BeiP1b1G.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ui/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
html { background: #ffffff; }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
<script type="module" crossorigin src="/assets/index-CE2Gq4kH.js"></script>
<script type="module" crossorigin src="/assets/index-BeiP1b1G.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DlmOL9K7.css">
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion ui/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"artifacts_tab_delete_folder": "حذف المجلد",
"artifacts_tab_failed_to_load": "تعذّر التحميل:",
"artifacts_tab_file_truncated_showing_the_first_512_kb": "تم اقتطاع الملف — عرض أول 512 كيلوبايت.",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "تم اقتطاع القائمة — يحتوي المجلد على مُخرَجات أخرى.",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "يتم عرض أول {limit} من المُخرَجات. البقية لا تزال على القرص في {dir}.",
"artifacts_tab_loading": "جارٍ التحميل…",
"artifacts_tab_loading_artifacts": "جارٍ تحميل المُخرَجات…",
"artifacts_tab_modified": "عُدّل",
Expand Down
2 changes: 1 addition & 1 deletion ui/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"artifacts_tab_delete_folder": "Delete folder",
"artifacts_tab_failed_to_load": "Failed to load:",
"artifacts_tab_file_truncated_showing_the_first_512_kb": "File truncated — showing the first 512 KB.",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "Listing truncated — the folder has more artifacts.",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "Showing the first {limit} artifacts. The rest are still on disk in {dir}.",
"artifacts_tab_loading": "Loading…",
"artifacts_tab_loading_artifacts": "Loading artifacts…",
"artifacts_tab_modified": "Modified",
Expand Down
2 changes: 1 addition & 1 deletion ui/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"artifacts_tab_delete_folder": "Eliminar carpeta",
"artifacts_tab_failed_to_load": "Error al cargar:",
"artifacts_tab_file_truncated_showing_the_first_512_kb": "Archivo truncado — se muestran los primeros 512 KB.",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "Listado truncado — la carpeta tiene más artefactos.",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "Se muestran los primeros {limit} artefactos. El resto sigue en el disco en {dir}.",
"artifacts_tab_loading": "Cargando…",
"artifacts_tab_loading_artifacts": "Cargando artefactos…",
"artifacts_tab_modified": "Modificado",
Expand Down
2 changes: 1 addition & 1 deletion ui/messages/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"artifacts_tab_delete_folder": "حذف پوشه",
"artifacts_tab_failed_to_load": "بارگیری ناموفق بود:",
"artifacts_tab_file_truncated_showing_the_first_512_kb": "فایل کوتاه شده است — فقط ۵۱۲ کیلوبایت نخست نمایش داده می‌شود.",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "فهرست کوتاه شده است — خروجی‌های بیشتری در پوشه وجود دارد.",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "نمایش {limit} خروجی نخست. بقیه همچنان روی دیسک در {dir} هستند.",
"artifacts_tab_loading": "در حال بارگیری…",
"artifacts_tab_loading_artifacts": "در حال بارگیری خروجی‌ها…",
"artifacts_tab_modified": "ویرایش‌شده",
Expand Down
2 changes: 1 addition & 1 deletion ui/messages/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"artifacts_tab_delete_folder": "फ़ोल्डर हटाएं",
"artifacts_tab_failed_to_load": "लोड नहीं हो सका:",
"artifacts_tab_file_truncated_showing_the_first_512_kb": "फ़ाइल छोटी की गई — पहले 512 KB दिखाए जा रहे हैं।",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "सूची छोटी की गई — इस फ़ोल्डर में और भी आर्टिफ़ैक्ट हैं।",
"artifacts_tab_listing_truncated_the_folder_has_more_artifacts": "पहले {limit} आर्टिफ़ैक्ट दिखाए जा रहे हैं। बाकी अभी भी डिस्क पर {dir} में हैं।",
"artifacts_tab_loading": "लोड हो रहा है…",
"artifacts_tab_loading_artifacts": "आर्टिफ़ैक्ट लोड हो रहे हैं…",
"artifacts_tab_modified": "बदला गया",
Expand Down
Loading
Loading