Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6435764
docs: plan filesystem collections release
devnull03 Sep 13, 2026
4deae92
feat(files): share recursive filesystem inventory
devnull03 Sep 14, 2026
a88ea00
feat(settings): persist archival row structure
devnull03 Sep 14, 2026
416f99b
feat(files): plan archival components from folders
devnull03 Sep 14, 2026
a5ba869
feat(settings): add archival description profiles
devnull03 Sep 14, 2026
223f526
feat(table): add archival hierarchy command model
devnull03 Sep 14, 2026
9c3721b
feat(table): display collapsible archival groups
devnull03 Sep 14, 2026
e02aea5
fix(settings): keep hierarchy migration lazy
devnull03 Sep 14, 2026
a319db3
feat(table): edit archival hierarchy with undo
devnull03 Sep 14, 2026
3a05ea2
feat(wizard): create hierarchy from folder trees
devnull03 Sep 14, 2026
d902a1b
feat(wizard): configure archival description standard
devnull03 Sep 14, 2026
a10c7e1
feat(table): remember expanded archival groups
devnull03 Sep 14, 2026
3924b8d
feat(table): import dropped filesystem components
devnull03 Sep 14, 2026
7ac29b8
feat(wizard): accept dropped project sources
devnull03 Sep 14, 2026
a47f639
feat(table): link files by dropping onto cells
devnull03 Sep 14, 2026
00fd19c
feat(table): drag archival components between parents
devnull03 Sep 14, 2026
13c6c3b
feat(settings): add archival structure column roles
devnull03 Sep 14, 2026
01223d0
feat(export): encode archival hierarchy in JSON-LD
devnull03 Sep 14, 2026
33a8e5b
feat(export): preserve collection paths in archives
devnull03 Sep 14, 2026
2e0e43d
feat(export): project archival structure into mapped columns
devnull03 Sep 14, 2026
64c7d04
chore: record file-ingest in data-exchange lock entry
devnull03 Sep 14, 2026
23acf1c
fix(table): save archival structure with its rows
devnull03 Sep 14, 2026
6431523
fix(export): keep typed values in structure columns
devnull03 Sep 14, 2026
890f02a
perf(wizard): index folder matches by filename key
devnull03 Sep 14, 2026
2891760
docs: plan duplicate handling for imports
devnull03 Sep 14, 2026
35b8c9a
feat(table): log project structure through load, create, and import
devnull03 Sep 16, 2026
32cbc2b
feat(file-ingest): resolve imports against what a project already holds
devnull03 Sep 16, 2026
b0e2cf7
feat(wizard): choose what happens to files named by several rows
devnull03 Sep 16, 2026
a5d620a
feat(table): import dropped files without copying what is already there
devnull03 Sep 16, 2026
6b6eeed
docs: record what shipped for import duplicates
devnull03 Sep 16, 2026
1d583c6
docs: drop Linear from the task workflow
devnull03 Sep 16, 2026
2cc8b6c
fix(preview): animate GIFs in cards and the Details pane
devnull03 Sep 16, 2026
3334724
feat(app): count characters, not words, in the cell status readout
devnull03 Sep 16, 2026
f5e2cc3
feat(wizard): load a column config onto an open project
devnull03 Sep 16, 2026
1b9e61d
feat(table): group rows and keep columns and file links in sync
devnull03 Sep 16, 2026
a1003a5
feat(import): add project setup preview and entry points
devnull03 Sep 21, 2026
083b51b
fix(table): land rows moved down beside their target
devnull03 Sep 22, 2026
0c5aef3
docs(settings): reattach the Google Sheet key's doc comment
devnull03 Sep 22, 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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,15 @@ The hub also has a **Document Hub** database (design docs, specs) linked from ta

Notion is where a task is born. Never create a Notion task from a GitHub issue — make it in Notion first, then sync it out to GitHub and record the issue URL in the task's `GitHub Link` property. The Notion UUID goes in the **PR body** (`Closes #N (Notion ID: <uuid>)`), never in the issue description.

**One task means an entry in every tracker.** Creating the Notion page is half the job — fan it out and cross-link, without being asked:
**One task means an entry in both trackers.** Creating the Notion page is half the job — fan it out and cross-link, without being asked:

1. Notion page in the Tasks Tracker.
2. `gh issue create --title "ASNT-<n>: <task name>"`, body ending `**Notion:** <page url>`. Match the structure existing issues use (Overview / Scope / Task Details / Definition of Done / Estimate / Type).
3. Write the issue URL back into the Notion `GitHub Link` property.

Linear is no longer used — don't create Linear issues or fill the `Linear Link` property.
**Linear is not used.** Never create a Linear issue, update the status of an existing one, or fill
the Notion `Linear Link` property — that property and the Linear issues behind it are leftovers.
Sub-task nesting and `Blocked by`/`Blocking` live in Notion.

## Code Style — no bloat

Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ members = [
"crates/clustering",
"crates/data-exchange",
"crates/diagnostics",
"crates/file-ingest",
"crates/plugin-api",
"crates/plugin-host",
"crates/plugin-package",
Expand Down
12 changes: 12 additions & 0 deletions crates/app/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ pub fn key_bindings() -> Vec<KeyBinding> {
KeyBinding::new("secondary-x", table::Cut, Some(table::GRID_CONTEXT)),
KeyBinding::new("secondary-c", table::Copy, Some(table::GRID_CONTEXT)),
KeyBinding::new("secondary-v", table::Paste, Some(table::GRID_CONTEXT)),
KeyBinding::new(
"secondary-alt-right",
table::ExpandAll,
Some(table::GRID_CONTEXT),
),
KeyBinding::new(
"secondary-alt-left",
table::CollapseAll,
Some(table::GRID_CONTEXT),
),
KeyBinding::new("secondary-]", table::IndentRow, Some(table::GRID_CONTEXT)),
KeyBinding::new("secondary-[", table::OutdentRow, Some(table::GRID_CONTEXT)),
// Blank the selection, the spreadsheet convention — and both keys, since Sheets and Excel
// accept either. Scoped to the grid like the clipboard keys above, which is what keeps
// Backspace deleting *text* while the cell editor or the find bar holds focus.
Expand Down
6 changes: 6 additions & 0 deletions crates/app/src/app_menus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ actions!(
/// The same window as `OpenSettings`, opened on the Columns page — the Data menu promises
/// a column surface, and landing on whatever page was last used does not keep that promise.
OpenColumnSettings,
/// Re-type the open project's columns from a column config file or Sheet.
LoadColumnConfig,
DiscoverPlugins,
ManagePlugins,
OpenPluginsFolder,
Expand Down Expand Up @@ -104,6 +106,9 @@ fn app_menus(cx: &gpui::App) -> Vec<Menu> {
MenuItem::Separator,
MenuItem::action("Open Projects…", OpenProjects),
MenuItem::action("Save", Save),
MenuItem::Separator,
MenuItem::action("Import Files or Folders…", table::ImportFiles),
MenuItem::action("Relink Missing Files…", table::RelinkMissingFiles),
MenuItem::submenu(Menu {
name: "Export".into(),
disabled: false,
Expand Down Expand Up @@ -193,6 +198,7 @@ fn app_menus(cx: &gpui::App) -> Vec<Menu> {
disabled: false,
items: vec![
MenuItem::action("Column Settings…", OpenColumnSettings),
MenuItem::action("Load Column Config…", LoadColumnConfig),
MenuItem::action("Rename Column…", RenameColumn),
MenuItem::Separator,
MenuItem::action("Delete Row", DeleteRow),
Expand Down
96 changes: 83 additions & 13 deletions crates/app/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::rc::Rc;

use data_exchange::export::{self, CSL_FIELDS, CslMapping};
use data_exchange::export::{self, ArchiveFile, CSL_FIELDS, CslMapping};
use gpui::{
Action, App, AppContext as _, ClickEvent, IntoElement, ParentElement, SharedString, Styled,
Window,
Expand All @@ -26,6 +26,13 @@ use settings::project::CurrentProject;
/// Where the CSL picker's answer is remembered, per project.
const CSL_MAPPING_KEY: &str = "csl_mapping";

struct ExportGrid {
headers: Vec<String>,
row_ids: Vec<settings::project::RowId>,
rows: Vec<Vec<String>>,
structure: Vec<settings::project::RowStructure>,
}

#[derive(Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
pub enum ExportFormat {
Csv,
Expand Down Expand Up @@ -202,12 +209,41 @@ pub fn run(format: ExportFormat, window: &mut Window, cx: &mut App) {
});
return;
}
let (Some(project), Some((headers, rows))) = (cx.try_global::<CurrentProject>(), grid(cx))
let (Some(project), Some((headers, mut rows))) = (cx.try_global::<CurrentProject>(), grid(cx))
else {
log::warn!("export was asked for with no project open");
return;
};
let (file, title) = (project.file.clone(), project.display_name());
let (row_ids, structure) = cx
.try_global::<table::TableStateHandle>()
.and_then(|handle| handle.0.upgrade())
.map(|state| {
let state = state.read(cx);
let (_, row_ids, _) = state.delegate().dataset_snapshot();
(row_ids, state.delegate().row_structure().to_vec())
})
.unwrap_or_default();
let declared: Vec<_> = project
.data
.columns
.iter()
.map(|column| {
(
column.name.clone(),
ColumnType::from_declared(&column.data_type),
)
})
.collect();
let description = settings::description::DescriptionConfig::from_values(&project.data.values);
export::project_structure_columns(
&headers,
&row_ids,
&mut rows,
&structure,
&declared,
&description,
);

if is_google(format) {
// A project that already knows its spreadsheet refills that one; otherwise "Sync" asks
Expand Down Expand Up @@ -240,26 +276,53 @@ pub fn run(format: ExportFormat, window: &mut Window, cx: &mut App) {
.unwrap_or_default();
table::photos::resolve_row_images(&headers, &rows, &folder, &declared)
.into_iter()
.flatten()
.enumerate()
.filter_map(|(index, path)| {
let path = path?;
let source_path = row_ids.get(index).and_then(|row_id| {
structure
.iter()
.find(|component| component.row_id == *row_id)
.and_then(|component| component.source_path.clone())
});
Some(ArchiveFile { path, source_path })
})
.collect()
} else {
Vec::new()
};

save_as(format, file, headers, rows, images, CslMapping::new(), cx);
save_as(
format,
file,
ExportGrid {
headers,
row_ids,
rows,
structure,
},
images,
CslMapping::new(),
cx,
);
}

/// Ask where it goes, then write it off the UI thread. The ZIP copies image bytes and the others
/// are a single small file, but they all wait on the same dialog, so they all go to the executor.
fn save_as(
format: ExportFormat,
project_file: PathBuf,
headers: Vec<String>,
rows: Vec<Vec<String>>,
images: Vec<PathBuf>,
grid: ExportGrid,
images: Vec<ArchiveFile>,
mapping: CslMapping,
cx: &mut App,
) {
let ExportGrid {
headers,
row_ids,
rows,
structure,
} = grid;
let directory = project_file
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
Expand All @@ -275,13 +338,16 @@ fn save_as(
};
let result = match format {
ExportFormat::Csv => export::write_csv(&path, &headers, &rows),
ExportFormat::JsonLd => {
export::write_json(&path, &export::jsonld_value(&headers, &rows))
}
ExportFormat::JsonLd => export::write_json(
&path,
&export::jsonld_hierarchy_value(&headers, &row_ids, &rows, &structure),
),
ExportFormat::Csl => {
export::write_json(&path, &export::csl_items(&headers, &rows, &mapping))
}
ExportFormat::Zip => export::write_zip(&path, &headers, &rows, &images),
ExportFormat::Zip => {
export::write_zip(&path, &headers, &row_ids, &rows, &structure, &images)
}
// Handled in `run` — they have no path to write to.
ExportFormat::GoogleSheet | ExportFormat::GoogleSheetSync => return,
};
Expand Down Expand Up @@ -393,8 +459,12 @@ fn ask_csl_mapping(
save_as(
ExportFormat::Csl,
project_file.clone(),
headers.clone(),
rows.clone(),
ExportGrid {
headers: headers.clone(),
row_ids: Vec::new(),
rows: rows.clone(),
structure: Vec::new(),
},
Vec::new(),
mapping,
cx,
Expand Down
9 changes: 7 additions & 2 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ use crate::app_settings::build_pages;
use crate::{
actions::{NewProject, ToggleBottomDock, ToggleLeftDock, ToggleRightDock},
app_menus::{
CopyDebugInfo, OpenAbout, OpenColumnSettings, OpenLogsFolder, OpenPluginsFolder,
OpenProjects, OpenSettings, Quit, ReloadPlugins, ReportBug, ReportUxIssue, RequestFeature,
CopyDebugInfo, LoadColumnConfig, OpenAbout, OpenColumnSettings, OpenLogsFolder,
OpenPluginsFolder, OpenProjects, OpenSettings, Quit, ReloadPlugins, ReportBug,
ReportUxIssue, RequestFeature,
},
status_items::build_status_bar_registry,
title_items::build_title_bar_registry,
Expand Down Expand Up @@ -291,6 +292,10 @@ impl Render for App {
.on_action(cx.listener(|_, action: &export::Export, window, cx| {
export::run(action.format, window, cx)
}))
// Here for the same reason: the dialog opens in this window.
.on_action(cx.listener(|_, _: &LoadColumnConfig, window, cx| {
project_wizard::open_column_config_dialog(window, cx)
}))
.on_action(
cx.listener(|_, action: &export::PluginExport, _, cx| {
export::run_plugin(action, cx)
Expand Down
16 changes: 11 additions & 5 deletions crates/app/src/status_items/cell_location.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! Right-side status-bar widget: shows the table's selection — `Row N, Col M, K words` for a
//! cell (word count of its text), `Row N` for a whole-row selection, `Col M` for a whole-column
//! Right-side status-bar widget: shows the table's selection — `Row N, Col M, K characters` for a
//! cell (character count of its text), `Row N` for a whole-row selection, `Col M` for a whole-column
//! selection. Text-only readout — sits leftmost in the right group.

use gpui::*;
Expand Down Expand Up @@ -52,12 +52,18 @@ impl CellLocation {
}
match state.delegate().selection() {
Some(Selection::Cell { row, col }) => {
let words = state
let characters = state
.delegate()
.cell(row, col)
.map(|t| t.split_whitespace().count())
.map(|t| t.chars().count())
.unwrap_or(0);
format!("Row {}, Col {}, {} words", row + 1, col + 1, words).into()
format!(
"Row {}, Col {}, {} characters",
row + 1,
col + 1,
characters
)
.into()
}
Some(Selection::Row(row)) => format!("Row {}", row + 1).into(),
Some(Selection::Column(col)) => format!("Col {}", col + 1).into(),
Expand Down
1 change: 1 addition & 0 deletions crates/data-exchange/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ csv.workspace = true
serde.workspace = true
serde_json.workspace = true
settings = { path = "../settings" }
file-ingest = { path = "../file-ingest" }
# blocking, not async: gpui's executor isn't a tokio reactor, so we run the
# one-shot fetch on the background executor with the blocking client.
reqwest = { version = "0.12", features = ["blocking", "json"] }
Expand Down
Loading
Loading