Skip to content

Commit ea1a883

Browse files
committed
feat(time-machine): auto-capture initial snapshot when project is added
When a project is added to PackageFlow, automatically capture an initial snapshot if a lockfile exists. This enables proper dependency change tracking when users later add/remove packages.
1 parent ca4f832 commit ea1a883

File tree

12 files changed

+174
-139
lines changed

12 files changed

+174
-139
lines changed

.github/workflows/release.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ jobs:
9393
build-macos:
9494
needs: generate-release-notes
9595
runs-on: macos-latest
96+
timeout-minutes: 60
9697
strategy:
9798
matrix:
9899
target: [aarch64-apple-darwin, x86_64-apple-darwin]
@@ -116,9 +117,26 @@ jobs:
116117
with:
117118
targets: ${{ matrix.target }}
118119

120+
- name: Cache Rust dependencies
121+
uses: Swatinem/rust-cache@v2
122+
with:
123+
workspaces: src-tauri
124+
cache-targets: true
125+
cache-all-crates: true
126+
key: ${{ matrix.target }}
127+
119128
- name: Install dependencies
120129
run: pnpm install
121130

131+
- name: Pre-build MCP server
132+
run: |
133+
# Build MCP with same target as Tauri to share compilation cache
134+
mkdir -p src-tauri/target/release
135+
touch src-tauri/target/release/packageflow-mcp
136+
cargo build --manifest-path src-tauri/Cargo.toml --release --target ${{ matrix.target }} --bin packageflow-mcp
137+
# Copy binary to expected location for Tauri resources
138+
cp src-tauri/target/${{ matrix.target }}/release/packageflow-mcp src-tauri/target/release/
139+
122140
- name: Import Apple certificate
123141
env:
124142
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}

README.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,10 @@
3535
<a href="#-faq"><strong>❓ FAQ</strong></a>
3636
</p>
3737

38-
<p align="center">
39-
<a href="./README.zh-TW.md">繁體中文</a> •
40-
<a href="./README.zh-CN.md">简体中文</a>
41-
</p>
42-
4338
---
4439

4540
<p align="center">
46-
<img src="docs/screenshots/deploy-demo.gif" width="720" alt="PackageFlow Hero" />
41+
<img src="docs/screenshots/chat-with-ai.gif" width="720" alt="PackageFlow Hero" />
4742
</p>
4843

4944
<!-- TODO: Add a 20–40s product demo video link (YouTube/X) and/or a thumbnail image here. -->

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ PackageFlow 是「AI 驱动」的意思不是只会聊天,而是 AI 可以调
9292
</p>
9393

9494
<p align="center">
95-
<img src="docs/screenshots/deploy-demo.gif" width="720" alt="Deploy demo" />
95+
<img src="docs/screenshots/chat-with-ai.gif" width="720" alt="Deploy demo" />
9696
<br/>
9797
<em>👆 一键部署,即时获取预览链接</em>
9898
</p>

README.zh-TW.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ PackageFlow 是「AI 驅動」的意思不是只會聊天,而是 AI 可以呼
9292
</p>
9393

9494
<p align="center">
95-
<img src="docs/screenshots/deploy-demo.gif" width="720" alt="Deploy demo" />
95+
<img src="docs/screenshots/chat-with-ai.gif" width="720" alt="Deploy demo" />
9696
<br/>
9797
<em>👆 一鍵部署,即時取得預覽連結</em>
9898
</p>

docs/screenshots/chat-with-ai.gif

76.5 MB
Loading

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
"dev:tauri": "tauri dev",
1010
"build": "tsc && vite build",
1111
"build:tauri": "tauri build --bundles dmg",
12-
"build:mcp": "cargo build --manifest-path src-tauri/Cargo.toml --bin packageflow-mcp && node scripts/ensure-mcp-alias.mjs --profile=debug",
13-
"build:mcp:release": "cargo build --manifest-path src-tauri/Cargo.toml --release --bin packageflow-mcp && node scripts/ensure-mcp-alias.mjs --profile=release",
12+
"build:mcp": "mkdir -p src-tauri/target/debug && touch src-tauri/target/debug/packageflow-mcp && cargo build --manifest-path src-tauri/Cargo.toml --bin packageflow-mcp && node scripts/ensure-mcp-alias.mjs --profile=debug",
13+
"build:mcp:release": "mkdir -p src-tauri/target/release && touch src-tauri/target/release/packageflow-mcp && cargo build --manifest-path src-tauri/Cargo.toml --release --bin packageflow-mcp && node scripts/ensure-mcp-alias.mjs --profile=release",
1414
"prebuild:tauri": "pnpm test:mcp && pnpm build:mcp:release",
1515
"predev:tauri": "pkill -f packageflow-mcp || true; pnpm build:mcp",
1616
"preview": "vite preview",

src-tauri/src/commands/project.rs

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ use chrono::Utc;
66
use serde::{Deserialize, Serialize};
77
use std::collections::HashMap;
88
use std::fs;
9-
use std::path::Path;
9+
use std::path::{Path, PathBuf};
1010
use uuid::Uuid;
1111

12+
use crate::models::snapshot::TriggerSource;
1213
use crate::models::{PackageManager, Project, WorkspacePackage};
1314
use crate::repositories::ProjectRepository;
15+
use crate::services::snapshot::{SnapshotCaptureService, SnapshotStorage};
1416
use crate::DatabaseState;
1517

1618
/// Response for scan_project command
@@ -308,14 +310,97 @@ pub async fn scan_project(
308310
})
309311
}
310312

313+
/// Get the snapshot storage base path
314+
fn get_storage_base_path() -> Result<PathBuf, String> {
315+
dirs::data_dir()
316+
.map(|p| p.join("com.packageflow.app").join("time-machine"))
317+
.ok_or_else(|| "Failed to get data directory".to_string())
318+
}
319+
311320
/// Save a project to SQLite database
321+
/// Also captures an initial snapshot if the project has a lockfile
312322
#[tauri::command]
313323
pub async fn save_project(
314324
db: tauri::State<'_, DatabaseState>,
315325
project: Project,
316326
) -> Result<(), String> {
317327
let repo = ProjectRepository::new(db.0.as_ref().clone());
318-
repo.save(&project)
328+
repo.save(&project)?;
329+
330+
// Capture initial snapshot in background
331+
let project_path = project.path.clone();
332+
let db_clone = db.0.as_ref().clone();
333+
334+
// Spawn a background task to capture initial snapshot
335+
tokio::spawn(async move {
336+
if let Err(e) = capture_initial_snapshot(&project_path, db_clone).await {
337+
log::warn!(
338+
"[project] Failed to capture initial snapshot for {}: {}",
339+
project_path,
340+
e
341+
);
342+
}
343+
});
344+
345+
Ok(())
346+
}
347+
348+
/// Capture initial snapshot for a newly added project
349+
async fn capture_initial_snapshot(
350+
project_path: &str,
351+
db: crate::utils::database::Database,
352+
) -> Result<(), String> {
353+
let path = Path::new(project_path);
354+
355+
// Check if project has a lockfile
356+
let has_lockfile = path.join("pnpm-lock.yaml").exists()
357+
|| path.join("package-lock.json").exists()
358+
|| path.join("yarn.lock").exists()
359+
|| path.join("bun.lockb").exists();
360+
361+
if !has_lockfile {
362+
log::info!(
363+
"[project] No lockfile found for {}, skipping initial snapshot",
364+
project_path
365+
);
366+
return Ok(());
367+
}
368+
369+
let base_path = get_storage_base_path()?;
370+
let project_path_owned = project_path.to_string();
371+
372+
// Run in blocking task since snapshot capture involves file I/O
373+
tokio::task::spawn_blocking(move || {
374+
let storage = SnapshotStorage::new(base_path);
375+
let service = SnapshotCaptureService::new(storage, db);
376+
377+
let request = crate::models::snapshot::CreateSnapshotRequest {
378+
project_path: project_path_owned.clone(),
379+
trigger_source: TriggerSource::Manual, // Initial snapshot
380+
};
381+
382+
match service.capture_snapshot(&request) {
383+
Ok(snapshot) => {
384+
log::info!(
385+
"[project] Captured initial snapshot {} for project {} ({} dependencies)",
386+
snapshot.id,
387+
project_path_owned,
388+
snapshot.total_dependencies
389+
);
390+
Ok(())
391+
}
392+
Err(e) => {
393+
log::error!(
394+
"[project] Failed to capture initial snapshot for {}: {}",
395+
project_path_owned,
396+
e
397+
);
398+
Err(e)
399+
}
400+
}
401+
})
402+
.await
403+
.map_err(|e| format!("Task join error: {}", e))?
319404
}
320405

321406
/// Remove a project from SQLite database

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"build": {
77
"beforeDevCommand": "pnpm dev",
88
"devUrl": "http://localhost:1420",
9-
"beforeBuildCommand": "pnpm build:mcp:release && pnpm build",
9+
"beforeBuildCommand": "pnpm build",
1010
"frontendDist": "../dist"
1111
},
1212
"app": {

src/components/project/ScriptTerminal.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
Search,
1717
} from 'lucide-react';
1818
import { type RunningScript } from '../../hooks/useScriptExecution';
19-
import { useTerminalHeight } from '../../hooks/useTerminalHeight';
19+
import { useSettings } from '../../contexts/SettingsContext';
2020
import { useTerminalSearch, highlightSearchMatches } from '../../hooks/useTerminalSearch';
2121
import AnsiToHtml from 'ansi-to-html';
2222

@@ -90,7 +90,29 @@ export function ScriptTerminal({
9090
const startYRef = useRef(0);
9191
const startHeightRef = useRef(0);
9292

93-
const { height, updateHeight, MIN_HEIGHT, MAX_HEIGHT } = useTerminalHeight();
93+
const { terminalHeight: height, setTerminalHeight } = useSettings();
94+
const MIN_HEIGHT = 100;
95+
const MAX_HEIGHT = 600;
96+
97+
// Debounce height updates to avoid excessive DB writes
98+
const updateHeightTimeoutRef = useRef<number | undefined>(undefined);
99+
const updateHeight = useCallback((newHeight: number) => {
100+
if (updateHeightTimeoutRef.current) {
101+
clearTimeout(updateHeightTimeoutRef.current);
102+
}
103+
updateHeightTimeoutRef.current = window.setTimeout(() => {
104+
setTerminalHeight(newHeight);
105+
}, 500);
106+
}, [setTerminalHeight]);
107+
108+
// Cleanup timeout on unmount
109+
useEffect(() => {
110+
return () => {
111+
if (updateHeightTimeoutRef.current) {
112+
clearTimeout(updateHeightTimeoutRef.current);
113+
}
114+
};
115+
}, []);
94116

95117
const activeScript = activeExecutionId ? runningScripts.get(activeExecutionId) : null;
96118

src/components/terminal/ScriptPtyTerminal.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import {
3030
Trash2,
3131
Terminal as TerminalIcon,
3232
} from 'lucide-react';
33-
import { useTerminalHeight } from '../../hooks/useTerminalHeight';
3433
import { scriptAPI } from '../../lib/tauri-api';
3534
import { useSettings } from '../../contexts/SettingsContext';
3635
import { Button } from '../ui/Button';
@@ -171,8 +170,30 @@ export const ScriptPtyTerminal = forwardRef<ScriptPtyTerminalRef, ScriptPtyTermi
171170
const startYRef = useRef(0);
172171
const startHeightRef = useRef(0);
173172

174-
// Settings context for path formatting
175-
const { formatPath } = useSettings();
173+
// Settings context for path formatting and terminal height
174+
const { formatPath, terminalHeight: height, setTerminalHeight } = useSettings();
175+
const MIN_HEIGHT = 100;
176+
const MAX_HEIGHT = 600;
177+
178+
// Debounce height updates to avoid excessive DB writes
179+
const updateHeightTimeoutRef = useRef<number | undefined>(undefined);
180+
const updateHeight = useCallback((newHeight: number) => {
181+
if (updateHeightTimeoutRef.current) {
182+
clearTimeout(updateHeightTimeoutRef.current);
183+
}
184+
updateHeightTimeoutRef.current = window.setTimeout(() => {
185+
setTerminalHeight(newHeight);
186+
}, 500);
187+
}, [setTerminalHeight]);
188+
189+
// Cleanup height update timeout on unmount
190+
useEffect(() => {
191+
return () => {
192+
if (updateHeightTimeoutRef.current) {
193+
clearTimeout(updateHeightTimeoutRef.current);
194+
}
195+
};
196+
}, []);
176197

177198
// Feature 008: Store callback refs to avoid stale closures in useEffect
178199
const onUpdatePtyOutputRef = useRef(onUpdatePtyOutput);
@@ -182,9 +203,6 @@ export const ScriptPtyTerminal = forwardRef<ScriptPtyTerminalRef, ScriptPtyTermi
182203
onUpdatePtyStatusRef.current = onUpdatePtyStatus;
183204
}, [onUpdatePtyOutput, onUpdatePtyStatus]);
184205

185-
// Use persisted height hook
186-
const { height, updateHeight, MIN_HEIGHT, MAX_HEIGHT } = useTerminalHeight();
187-
188206
// Get active session
189207
const activeSession = activeSessionId ? sessions.get(activeSessionId) : null;
190208

0 commit comments

Comments
 (0)