Skip to content

Commit 242f58e

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 242f58e

File tree

10 files changed

+155
-138
lines changed

10 files changed

+155
-138
lines changed

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/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

src/components/time-machine/SnapshotDetailPanel.tsx

Lines changed: 15 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export function SnapshotDetailPanel({
166166
];
167167

168168
return (
169-
<div className={cn('flex h-full -m-4 animate-in fade-in-0 duration-200', className)}>
169+
<div className={cn('flex -m-4 animate-in fade-in-0 duration-200', className)}>
170170
{/* Left Sidebar Navigation */}
171171
<div className="w-56 flex-shrink-0 bg-card rounded-lg overflow-hidden m-4 mr-0 self-start">
172172
<div className="p-3 border-b border-border">
@@ -225,14 +225,14 @@ export function SnapshotDetailPanel({
225225
</div>
226226

227227
{/* Right Content Area */}
228-
<div className="flex-1 min-w-0 overflow-hidden p-4 flex flex-col">
228+
<div className="flex-1 min-w-0 p-4">
229229
{/* Overview Tab */}
230-
<div className={cn('flex-1 overflow-y-auto', activeTab !== 'overview' && 'hidden')}>
230+
<div className={cn(activeTab !== 'overview' && 'hidden')}>
231231
<OverviewTabContent snapshot={snapshot} />
232232
</div>
233233

234234
{/* Dependencies Tab */}
235-
<div className={cn('flex-1 overflow-y-auto', activeTab !== 'dependencies' && 'hidden')}>
235+
<div className={cn(activeTab !== 'dependencies' && 'hidden')}>
236236
<DependenciesTabContent
237237
dependencies={dependencies}
238238
loading={loading}
@@ -252,12 +252,12 @@ export function SnapshotDetailPanel({
252252
</div>
253253

254254
{/* Integrity Tab */}
255-
<div className={cn('flex-1 overflow-y-auto', activeTab !== 'integrity' && 'hidden')}>
255+
<div className={cn(activeTab !== 'integrity' && 'hidden')}>
256256
<IntegrityTabContent snapshot={snapshot} />
257257
</div>
258258

259259
{/* Compare Tab */}
260-
<div className={cn('flex-1 overflow-y-auto', activeTab !== 'compare' && 'hidden')}>
260+
<div className={cn(activeTab !== 'compare' && 'hidden')}>
261261
<CompareTabContent
262262
currentSnapshotId={snapshot.id}
263263
allSnapshots={allSnapshots}
@@ -293,33 +293,23 @@ function OverviewTabContent({ snapshot }: { snapshot: ExecutionSnapshot }) {
293293
<div>
294294
<h2 className="text-lg font-semibold text-foreground">Snapshot Overview</h2>
295295
<p className="text-sm text-muted-foreground mt-1">
296-
{snapshot.triggerSource === 'manual' ? 'Manual capture' : 'Auto-captured on lockfile change'}
296+
{snapshot.triggerSource === 'manual'
297+
? 'Manual capture'
298+
: 'Auto-captured on lockfile change'}
297299
</p>
298300
</div>
299301

300302
{/* Stats Grid */}
301303
<div className="grid grid-cols-2 gap-4">
302-
<StatCard
303-
icon={Clock}
304-
label="Created"
305-
value={formatDate(snapshot.createdAt)}
306-
/>
304+
<StatCard icon={Clock} label="Created" value={formatDate(snapshot.createdAt)} />
307305
<StatCard
308306
icon={Package}
309307
label="Package Manager"
310308
value={(snapshot.lockfileType || 'Unknown').toUpperCase()}
311309
/>
312-
<StatCard
313-
icon={HardDrive}
314-
label="Storage"
315-
value={formatSize(snapshot.compressedSize)}
316-
/>
310+
<StatCard icon={HardDrive} label="Storage" value={formatSize(snapshot.compressedSize)} />
317311
<div
318-
className={cn(
319-
'p-4 rounded-xl',
320-
'bg-card border border-border',
321-
'flex items-start gap-3'
322-
)}
312+
className={cn('p-4 rounded-xl', 'bg-card border border-border', 'flex items-start gap-3')}
323313
>
324314
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-cyan-500/10 flex items-center justify-center">
325315
<Shield className="w-5 h-5 text-cyan-500" />
@@ -384,13 +374,7 @@ function StatCard({
384374
value: string;
385375
}) {
386376
return (
387-
<div
388-
className={cn(
389-
'p-4 rounded-xl',
390-
'bg-card border border-border',
391-
'flex items-start gap-3'
392-
)}
393-
>
377+
<div className={cn('p-4 rounded-xl', 'bg-card border border-border', 'flex items-start gap-3')}>
394378
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-cyan-500/10 flex items-center justify-center">
395379
<Icon className="w-5 h-5 text-cyan-500" />
396380
</div>
@@ -479,11 +463,7 @@ function DependenciesTabContent({
479463
active={showDirect}
480464
onClick={() => onShowDirectChange(!showDirect)}
481465
/>
482-
<FilterPill
483-
label="Dev"
484-
active={showDev}
485-
onClick={() => onShowDevChange(!showDev)}
486-
/>
466+
<FilterPill label="Dev" active={showDev} onClick={() => onShowDevChange(!showDev)} />
487467
<FilterPill
488468
label="Postinstall"
489469
active={showPostinstall}
@@ -511,9 +491,7 @@ function DependenciesTabContent({
511491
{directDeps.length > 0 && (
512492
<DependencyGroup title="Direct" deps={directDeps} variant="primary" />
513493
)}
514-
{devDeps.length > 0 && (
515-
<DependencyGroup title="Dev" deps={devDeps} variant="secondary" />
516-
)}
494+
{devDeps.length > 0 && <DependencyGroup title="Dev" deps={devDeps} variant="secondary" />}
517495
{transitiveDeps.length > 0 && (
518496
<DependencyGroup title="Transitive" deps={transitiveDeps} variant="muted" />
519497
)}

0 commit comments

Comments
 (0)