make git information reactive

This commit is contained in:
Nikita Galaiko 2023-03-29 12:39:24 +02:00
parent 1f70f359d7
commit 88c731c50a
27 changed files with 387 additions and 394 deletions

3
.gitignore vendored
View File

@ -28,3 +28,6 @@ dist-ssr
/package
!.env.example
vite.config.js.timestamp-*
# gitbutler
.git/gb-*

View File

@ -15,14 +15,27 @@ impl Event {
}
}
pub fn git(project: &projects::Project) -> Self {
let event_name = format!("project://{}/git", project.id);
let payload = serde_json::json!({
"logs/HEAD": "updated",
});
pub fn git_index(project: &projects::Project) -> Self {
let event_name = format!("project://{}/git/index", project.id);
Event {
name: event_name,
payload: payload,
payload: serde_json::json!({}),
}
}
pub fn git_head(project: &projects::Project, head: &str) -> Self {
let event_name = format!("project://{}/git/head", project.id);
Event {
name: event_name,
payload: serde_json::json!({ "head": head }),
}
}
pub fn git_activity(project: &projects::Project) -> Self {
let event_name = format!("project://{}/git/activity", project.id);
Event {
name: event_name,
payload: serde_json::json!({}),
}
}

3
src-tauri/src/git/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod activity;
#[cfg(test)]
mod activity_tests;

View File

@ -1,6 +1,7 @@
mod deltas;
mod events;
mod fs;
mod git;
mod projects;
mod repositories;
mod search;
@ -14,6 +15,7 @@ extern crate log;
use anyhow::{Context, Result};
use deltas::Delta;
use git::activity;
use serde::{ser::SerializeMap, Serialize};
use std::{collections::HashMap, ops::Range, sync::Mutex};
use storage::Storage;
@ -373,12 +375,26 @@ async fn list_deltas(
Ok(deltas)
}
#[timed(duration(printer = "debug!"))]
#[tauri::command(async)]
async fn git_activity(
handle: tauri::AppHandle,
project_id: &str,
start_time_ms: Option<u128>,
) -> Result<Vec<activity::Activity>, Error> {
let repo = repo_for_project(handle, project_id)?;
let activity = repo
.activity(start_time_ms)
.with_context(|| "Failed to get git activity")?;
Ok(activity)
}
#[timed(duration(printer = "debug!"))]
#[tauri::command(async)]
async fn git_status(
handle: tauri::AppHandle,
project_id: &str,
) -> Result<HashMap<String, String>, Error> {
) -> Result<HashMap<String, repositories::FileStatus>, Error> {
let repo = repo_for_project(handle, project_id)?;
let files = repo.status().with_context(|| "Failed to get git status")?;
Ok(files)
@ -454,12 +470,12 @@ async fn git_branches(handle: tauri::AppHandle, project_id: &str) -> Result<Vec<
#[timed(duration(printer = "debug!"))]
#[tauri::command(async)]
async fn git_branch(handle: tauri::AppHandle, project_id: &str) -> Result<String, Error> {
async fn git_head(handle: tauri::AppHandle, project_id: &str) -> Result<String, Error> {
let repo = repo_for_project(handle, project_id)?;
let files = repo
.branch()
let head = repo
.head()
.with_context(|| "Failed to get the git branch ref name")?;
Ok(files)
Ok(head)
}
#[timed(duration(printer = "debug!"))]
@ -611,9 +627,10 @@ fn main() {
get_user,
search,
git_status,
git_activity,
git_match_paths,
git_branches,
git_branch,
git_head,
git_switch_branch,
git_commit,
git_wd_diff,

View File

@ -1,7 +1,7 @@
mod repository;
mod storage;
pub use repository::Repository;
pub use repository::{Repository, FileStatus};
pub use storage::Store;
#[cfg(test)]

View File

@ -1,6 +1,7 @@
use crate::{deltas, fs, projects, sessions, users};
use crate::{deltas, git::activity, projects, sessions, users};
use anyhow::{Context, Result};
use git2::{BranchType, Cred, DiffOptions, Signature};
use serde::Serialize;
use std::{
collections::HashMap,
env, fs as std_fs,
@ -10,6 +11,17 @@ use std::{
use tauri::regex::Regex;
use walkdir::WalkDir;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub enum FileStatus {
Added,
Modified,
Deleted,
Renamed,
TypeChange,
Other,
}
#[derive(Clone)]
pub struct Repository {
pub project: projects::Project,
@ -124,12 +136,14 @@ impl Repository {
Ok(branches)
}
// return current branch name
pub fn branch(&self) -> Result<String> {
// return current head name
pub fn head(&self) -> Result<String> {
let repo = self.git_repository.lock().unwrap();
let head = repo.head()?;
let branch = head.name().unwrap();
Ok(branch.to_string())
Ok(head
.name()
.map(|s| s.to_string())
.unwrap_or("undefined".to_string()))
}
// return file contents for path in the working directory
@ -214,8 +228,36 @@ impl Repository {
Ok(true)
}
pub fn activity(&self, start_time_ms: Option<u128>) -> Result<Vec<activity::Activity>> {
let head_logs_path = Path::new(&self.project.path)
.join(".git")
.join("logs")
.join("HEAD");
if !head_logs_path.exists() {
return Ok(Vec::new());
}
let activity = std::fs::read_to_string(head_logs_path)
.with_context(|| "failed to read HEAD logs")?
.lines()
.filter_map(|line| activity::parse_reflog_line(line).ok())
.collect::<Vec<activity::Activity>>();
let activity = if let Some(start_timestamp_ms) = start_time_ms {
activity
.into_iter()
.filter(|activity| activity.timestamp_ms > start_timestamp_ms)
.collect::<Vec<activity::Activity>>()
} else {
activity
};
Ok(activity)
}
// get file status from git
pub fn status(&self) -> Result<HashMap<String, String>> {
pub fn status(&self) -> Result<HashMap<String, FileStatus>> {
let mut options = git2::StatusOptions::new();
options.include_untracked(true);
options.include_ignored(false);
@ -235,19 +277,19 @@ impl Repository {
let path = entry.path().unwrap();
// get the status as a string
let istatus = match entry.status() {
s if s.contains(git2::Status::WT_NEW) => "added",
s if s.contains(git2::Status::WT_MODIFIED) => "modified",
s if s.contains(git2::Status::WT_DELETED) => "deleted",
s if s.contains(git2::Status::WT_RENAMED) => "renamed",
s if s.contains(git2::Status::WT_TYPECHANGE) => "typechange",
s if s.contains(git2::Status::INDEX_NEW) => "added",
s if s.contains(git2::Status::INDEX_MODIFIED) => "modified",
s if s.contains(git2::Status::INDEX_DELETED) => "deleted",
s if s.contains(git2::Status::INDEX_RENAMED) => "renamed",
s if s.contains(git2::Status::INDEX_TYPECHANGE) => "typechange",
_ => "other",
git2::Status::WT_NEW => FileStatus::Added,
git2::Status::WT_MODIFIED => FileStatus::Modified,
git2::Status::WT_DELETED => FileStatus::Deleted,
git2::Status::WT_RENAMED => FileStatus::Renamed,
git2::Status::WT_TYPECHANGE => FileStatus::TypeChange,
git2::Status::INDEX_NEW => FileStatus::Added,
git2::Status::INDEX_MODIFIED => FileStatus::Modified,
git2::Status::INDEX_DELETED => FileStatus::Deleted,
git2::Status::INDEX_RENAMED => FileStatus::Renamed,
git2::Status::INDEX_TYPECHANGE => FileStatus::TypeChange,
_ => FileStatus::Other,
};
files.insert(path.to_string(), istatus.to_string());
files.insert(path.to_string(), istatus);
}
return Ok(files);

View File

@ -1,11 +1,8 @@
mod activity;
mod sessions;
mod storage;
pub use sessions::{id_from_commit, Meta, Session};
pub use storage::Store;
#[cfg(test)]
mod activity_tests;
#[cfg(test)]
mod sessions_tests;

View File

@ -1,6 +1,6 @@
use std::path::Path;
use super::activity;
use crate::git::activity;
use anyhow::{anyhow, Context, Result};
use serde::Serialize;

View File

@ -3,7 +3,7 @@ use std::{
time,
};
use crate::{projects, sessions};
use crate::{git, projects, sessions};
use anyhow::{Context, Result};
use uuid::Uuid;
@ -32,9 +32,9 @@ impl Store {
let activity = match std::fs::read_to_string(git_repository.path().join("logs/HEAD")) {
Ok(reflog) => reflog
.lines()
.filter_map(|line| sessions::activity::parse_reflog_line(line).ok())
.filter_map(|line| git::activity::parse_reflog_line(line).ok())
.filter(|activity| activity.timestamp_ms >= now_ts)
.collect::<Vec<sessions::activity::Activity>>(),
.collect::<Vec<git::activity::Activity>>(),
Err(_) => Vec::new(),
};
@ -221,9 +221,9 @@ impl Store {
)
})?
.lines()
.filter_map(|line| sessions::activity::parse_reflog_line(line).ok())
.filter_map(|line| git::activity::parse_reflog_line(line).ok())
.filter(|activity| activity.timestamp_ms >= start_ts)
.collect::<Vec<sessions::activity::Activity>>(),
.collect::<Vec<git::activity::Activity>>(),
false => Vec::new(),
};

View File

@ -1,22 +1,29 @@
use crate::{events, projects};
use crate::{events, repositories};
use anyhow::{Context, Result};
use std::path::Path;
pub async fn on_git_file_change<P: AsRef<Path>>(
sender: &tokio::sync::mpsc::Sender<events::Event>,
project: &projects::Project,
repository: &repositories::Repository,
path: P,
) -> Result<()> {
if path.as_ref().ne(Path::new(".git/logs/HEAD")) {
let event = if path.as_ref().eq(Path::new(".git/logs/HEAD")) {
events::Event::git_activity(&repository.project)
} else if path.as_ref().eq(Path::new(".git/HEAD")) {
events::Event::git_head(&repository.project, &repository.head()?)
} else if path.as_ref().eq(Path::new(".git/index")) {
events::Event::git_index(&repository.project)
} else {
return Ok(());
}
let event = events::Event::git(&project);
};
sender.send(event).await.with_context(|| {
format!(
"{}: failed to send git event for \"{}\"",
project.id,
repository.project.id,
path.as_ref().to_str().unwrap()
)
})?;
Ok(())
}

View File

@ -52,6 +52,7 @@ impl Watcher {
let shared_sender = Arc::new(sender.clone());
let shared_deltas_store = Arc::new(repository.deltas_storage.clone());
let shared_lock_file = Arc::new(tokio::sync::Mutex::new(lock_file));
let shared_repository = Arc::new(repository.clone());
self.session_watcher
.watch(sender, shared_lock_file.clone(), repository)?;
@ -62,11 +63,13 @@ impl Watcher {
let sender = shared_sender;
let deltas_storage = shared_deltas_store;
let lock_file = shared_lock_file;
let repository = shared_repository;
while let Some(event) = fsevents.recv().await {
match event {
files::Event::FileChange((project, path)) => {
if path.starts_with(Path::new(".git")) {
if let Err(e) = git::on_git_file_change(&sender, &project, &path).await
if let Err(e) =
git::on_git_file_change(&sender, &repository, &path).await
{
log::error!("{}: {:#}", project.id, e);
}

View File

@ -1,2 +0,0 @@
export const toHumanBranchName = (branch: string | undefined) =>
branch ? branch.replace('refs/heads/', '') : 'master';

View File

@ -1,47 +0,0 @@
<script lang="ts">
import { toHumanBranchName } from '$lib/branch';
export let startTime: Date;
export let endTime: Date;
export let label: string;
export let href: string;
const timeToGridRow = (time: Date) => {
const hours = time.getHours();
const minutes = time.getMinutes();
const totalMinutes = hours * 60 + minutes;
const totalMinutesPerDay = 24 * 60;
const gridRow = Math.floor((totalMinutes / totalMinutesPerDay) * 96);
return gridRow + 1; // offset the first row
};
const dateToGridCol = (date: Date) => {
return date.getDay();
};
const timeToSpan = (startTime: Date, endTime: Date) => {
const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
const endMinutes = endTime.getHours() * 60 + endTime.getMinutes();
const span = Math.round((endMinutes - startMinutes) / 15); // 4 spans per hour
if (span < 1) {
return 1;
} else {
return span;
}
};
</script>
<li
class="relative mt-px flex col-start-{dateToGridCol(startTime)}"
style="grid-row: {timeToGridRow(startTime)} / span {timeToSpan(startTime, endTime)};"
>
<a
{href}
title={startTime.toLocaleTimeString()}
class="group absolute inset-1 flex flex-col items-center justify-center rounded-lg bg-zinc-300 p-3 leading-5 shadow hover:bg-zinc-200"
>
<p class="order-1 font-semibold text-zinc-800">
{toHumanBranchName(label)}
</p>
</a>
</li>

View File

@ -1 +0,0 @@
export { default as WeekBlockEntry } from './WeekBlockEntry.svelte';

27
src/lib/git/activity.ts Normal file
View File

@ -0,0 +1,27 @@
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import { writable, type Readable } from 'svelte/store';
import { log } from '$lib';
export type Activity = {
type: string;
timestampMs: number;
message: string;
};
const list = (params: { projectId: string; startTimeMs?: number }) =>
invoke<Activity[]>('git_activity', params);
export default async (params: { projectId: string }) => {
const activity = await list(params);
const store = writable(activity);
appWindow.listen(`project://${params.projectId}/git/activity`, async () => {
log.info(`Status: Received git activity event, projectId: ${params.projectId}`);
const startTimeMs = activity.at(-1)?.timestampMs;
const newActivities = await list({ projectId: params.projectId, startTimeMs });
store.update((activities) => [...activities, ...newActivities]);
});
return store as Readable<Activity[]>;
};

18
src/lib/git/head.ts Normal file
View File

@ -0,0 +1,18 @@
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import { derived, writable } from 'svelte/store';
import { log } from '$lib';
const list = (params: { projectId: string }) => invoke<string>('git_head', params);
export default async (params: { projectId: string }) => {
const head = await list(params);
const store = writable(head);
appWindow.listen<{ head: string }>(`project://${params.projectId}/git/head`, async (payload) => {
log.info(`Status: Received git head event, projectId: ${params.projectId}`);
store.set(payload.payload.head);
});
return derived(store, (head) => head.replace('refs/heads/', ''));
};

2
src/lib/git/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { default as statuses } from './statuses';
export { default as activity } from './activity';

37
src/lib/git/statuses.ts Normal file
View File

@ -0,0 +1,37 @@
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import { writable, type Readable } from 'svelte/store';
import { log } from '$lib';
export type Status = {
path: string;
status: FileStatus;
};
type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'typeChange' | 'other';
const list = (params: { projectId: string }) =>
invoke<Record<string, FileStatus>>('git_status', params);
const convertToStatuses = (statusesGit: Record<string, FileStatus>): Status[] =>
Object.entries(statusesGit).map((status) => ({
path: status[0],
status: status[1]
}));
export default async (params: { projectId: string }) => {
const statuses = await list(params).then(convertToStatuses);
const store = writable(statuses);
appWindow.listen(`project://${params.projectId}/git/index`, async () => {
log.info(`Status: Received git index event, projectId: ${params.projectId}`);
store.set(await list(params).then(convertToStatuses));
});
appWindow.listen(`project://${params.projectId}/sessions`, async () => {
log.info(`Status: Received sessions event, projectId: ${params.projectId}`);
store.set(await list(params).then(convertToStatuses));
});
return store as Readable<Status[]>;
};

View File

@ -2,102 +2,97 @@ import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import { writable, type Readable } from 'svelte/store';
import { log } from '$lib';
export type Activity = {
type: string;
timestampMs: number;
message: string;
};
import type { Activity } from './git/activity';
export namespace Session {
export const within = (session: Session | undefined, timestampMs: number) => {
if (!session) return false;
const { startTimestampMs, lastTimestampMs } = session.meta;
return startTimestampMs <= timestampMs && timestampMs <= lastTimestampMs;
};
export const within = (session: Session | undefined, timestampMs: number) => {
if (!session) return false;
const { startTimestampMs, lastTimestampMs } = session.meta;
return startTimestampMs <= timestampMs && timestampMs <= lastTimestampMs;
};
}
export type Session = {
id: string;
hash?: string;
meta: {
startTimestampMs: number;
lastTimestampMs: number;
branch?: string;
commit?: string;
};
activity: Activity[];
id: string;
hash?: string;
meta: {
startTimestampMs: number;
lastTimestampMs: number;
branch?: string;
commit?: string;
};
activity: Activity[];
};
const filesCache: Record<string, Record<string, Promise<Record<string, string>>>> = {};
export const listFiles = async (params: {
projectId: string;
sessionId: string;
paths?: string[];
projectId: string;
sessionId: string;
paths?: string[];
}) => {
const sessionFilesCache = filesCache[params.projectId] || {};
if (params.sessionId in sessionFilesCache) {
return sessionFilesCache[params.sessionId].then((files) => {
return Object.fromEntries(
Object.entries(files).filter(([path]) =>
params.paths ? params.paths.includes(path) : true
)
);
});
}
const sessionFilesCache = filesCache[params.projectId] || {};
if (params.sessionId in sessionFilesCache) {
return sessionFilesCache[params.sessionId].then((files) => {
return Object.fromEntries(
Object.entries(files).filter(([path]) =>
params.paths ? params.paths.includes(path) : true
)
);
});
}
const promise = invoke<Record<string, string>>('list_session_files', {
sessionId: params.sessionId,
projectId: params.projectId
});
sessionFilesCache[params.sessionId] = promise;
filesCache[params.projectId] = sessionFilesCache;
return promise.then((files) => {
return Object.fromEntries(
Object.entries(files).filter(([path]) => (params.paths ? params.paths.includes(path) : true))
);
});
const promise = invoke<Record<string, string>>('list_session_files', {
sessionId: params.sessionId,
projectId: params.projectId
});
sessionFilesCache[params.sessionId] = promise;
filesCache[params.projectId] = sessionFilesCache;
return promise.then((files) => {
return Object.fromEntries(
Object.entries(files).filter(([path]) => (params.paths ? params.paths.includes(path) : true))
);
});
};
const sessionsCache: Record<string, Promise<Session[]>> = {};
const list = async (params: { projectId: string; earliestTimestampMs?: number }) => {
if (params.projectId in sessionsCache) {
return sessionsCache[params.projectId].then((sessions) =>
sessions.filter((s) =>
params.earliestTimestampMs ? s.meta.startTimestampMs >= params.earliestTimestampMs : true
)
);
}
sessionsCache[params.projectId] = invoke<Session[]>('list_sessions', {
projectId: params.projectId
});
return sessionsCache[params.projectId].then((sessions) =>
sessions.filter((s) =>
params.earliestTimestampMs ? s.meta.startTimestampMs >= params.earliestTimestampMs : true
)
);
if (params.projectId in sessionsCache) {
return sessionsCache[params.projectId].then((sessions) =>
sessions.filter((s) =>
params.earliestTimestampMs ? s.meta.startTimestampMs >= params.earliestTimestampMs : true
)
);
}
sessionsCache[params.projectId] = invoke<Session[]>('list_sessions', {
projectId: params.projectId
});
return sessionsCache[params.projectId].then((sessions) =>
sessions.filter((s) =>
params.earliestTimestampMs ? s.meta.startTimestampMs >= params.earliestTimestampMs : true
)
);
};
export default async (params: { projectId: string; earliestTimestampMs?: number }) => {
const store = writable([] as Session[]);
list(params).then((sessions) => {
store.set(sessions);
});
const store = writable([] as Session[]);
list(params).then((sessions) => {
store.set(sessions);
});
appWindow.listen<Session>(`project://${params.projectId}/sessions`, async (event) => {
log.info(`Received sessions event, projectId: ${params.projectId}`);
const session = event.payload;
store.update((sessions) => {
const index = sessions.findIndex((session) => session.id === event.payload.id);
if (index === -1) {
return [...sessions, session];
} else {
return [...sessions.slice(0, index), session, ...sessions.slice(index + 1)];
}
});
});
appWindow.listen<Session>(`project://${params.projectId}/sessions`, async (event) => {
log.info(`Received sessions event, projectId: ${params.projectId}`);
const session = event.payload;
store.update((sessions) => {
const index = sessions.findIndex((session) => session.id === event.payload.id);
if (index === -1) {
return [...sessions, session];
} else {
return [...sessions.slice(0, index), session, ...sessions.slice(index + 1)];
}
});
});
return store as Readable<Session[]>;
return store as Readable<Session[]>;
};

View File

@ -1,47 +0,0 @@
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import { writable, type Readable } from 'svelte/store';
import { log } from '$lib';
import type { Session } from '$lib/sessions';
export type Status = {
path: string;
status: string;
};
const listFiles = (params: { projectId: string }) =>
invoke<Record<string, string>>('git_status', params);
function convertToStatuses(statusesGit: Record<string, string>): Status[] {
return Object.entries(statusesGit).map((status) => {
return {
path: status[0],
status: status[1]
};
});
}
export default async (params: { projectId: string }) => {
const statuses: Status[] = [];
listFiles(params).then((statuses) => {
store.set(convertToStatuses(statuses));
});
const store = writable(statuses);
appWindow.listen(`project://${params.projectId}/git`, async (event) => {
log.info(`Status: Received git event, projectId: ${params.projectId}`);
const statusesGit = await listFiles(params);
const statuses = convertToStatuses(statusesGit);
store.set(statuses);
});
appWindow.listen<Session>(`project://${params.projectId}/sessions`, async (event) => {
log.info(`Status: Received sessions event, projectId: ${params.projectId}`);
const statusesGit = await listFiles(params);
const statuses = convertToStatuses(statusesGit);
store.set(statuses);
});
return store as Readable<Status[]>;
};

View File

@ -1,60 +1,26 @@
import type { LayoutLoad } from './$types';
import { building } from '$app/environment';
import { readable, derived } from 'svelte/store';
import type { Session } from '$lib/sessions';
import type { Status } from '$lib/statuses';
import type { Activity } from '$lib/sessions';
import { subDays, getTime } from 'date-fns';
import type { Status } from '$lib/git/statuses';
import { readable } from 'svelte/store';
import type { LayoutLoad } from './$types';
export const prerender = false;
export const load: LayoutLoad = async ({ parent, params }) => {
const { projects } = await parent();
const filesStatus = building
? readable<Status[]>([])
: await (await import('$lib/statuses')).default({ projectId: params.projectId });
const sessionsFromLastFourDays = building
? readable<Session[]>([])
: await (
await import('$lib/sessions')
).default({
projectId: params.projectId,
earliestTimestampMs: getTime(subDays(new Date(), 4))
});
const orderedSessionsFromLastFourDays = derived(sessionsFromLastFourDays, (sessions) => {
return sessions.slice().sort((a, b) => a.meta.startTimestampMs - b.meta.startTimestampMs);
});
const recentActivity = derived(sessionsFromLastFourDays, (sessions) => {
const recentActivity: Activity[] = [];
sessions.forEach((session) => {
session.activity.forEach((activity) => {
recentActivity.push(activity);
});
});
const activitySorted = recentActivity.sort((a, b) => {
return b.timestampMs - a.timestampMs;
});
return activitySorted.slice(0, 20);
});
const user = building
? {
...readable<undefined>(undefined),
set: () => {
throw new Error('not implemented');
},
delete: () => {
throw new Error('not implemented');
}
}
: await (await import('$lib/users')).default();
return {
user: user,
project: projects.get(params.projectId),
projectId: params.projectId,
orderedSessionsFromLastFourDays: orderedSessionsFromLastFourDays,
filesStatus: filesStatus,
recentActivity: recentActivity
};
const { projects } = await parent();
const sessions = building
? readable<Session[]>([])
: await import('$lib/sessions').then((m) => m.default({ projectId: params.projectId }));
const statuses = building
? readable<Status[]>([])
: await import('$lib/git/statuses').then((m) => m.default({ projectId: params.projectId }));
const head = building
? readable<string>('')
: await import('$lib/git/head').then((m) => m.default({ projectId: params.projectId }));
return {
head,
statuses,
sessions,
project: projects.get(params.projectId),
projectId: params.projectId
};
};

View File

@ -1,19 +1,14 @@
<script lang="ts">
import type { LayoutData } from './$types';
import type { Session } from '$lib/sessions';
import { format, startOfDay } from 'date-fns';
import { format, getTime, startOfDay, subDays } from 'date-fns';
import type { Delta } from '$lib/deltas';
import { collapsable } from '$lib/paths';
import { invoke } from '@tauri-apps/api';
import { toHumanBranchName } from '$lib/branch';
import { list as listDeltas } from '$lib/deltas';
const getBranch = (params: { projectId: string }) => invoke<string>('git_branch', params);
import type { PageData } from './$types';
import { derived } from 'svelte/store';
export let data: LayoutData;
$: project = data.project;
$: filesStatus = data.filesStatus;
$: recentActivity = data.recentActivity;
$: orderedSessionsFromLastFourDays = data.orderedSessionsFromLastFourDays;
export let data: PageData;
const { activity, project, statuses, sessions, head } = data;
let latestDeltasByDateByFile: Record<number, Record<string, Delta[][]>[]> = {};
@ -29,10 +24,24 @@
}
}
const recentSessions = derived(sessions, (sessions) => {
const lastFourDaysOfSessions = sessions.filter(
(session) => session.meta.startTimestampMs >= getTime(subDays(new Date(), 4))
);
if (lastFourDaysOfSessions.length >= 4) return lastFourDaysOfSessions;
return sessions.slice(0, 4);
});
const recentActivity = derived([activity, recentSessions], ([activity, recentSessions]) =>
activity
.filter((a) => a.timestampMs >= (recentSessions.at(-1)?.meta.startTimestampMs ?? 0))
.sort((a, b) => b.timestampMs - a.timestampMs)
);
$: if ($project) {
latestDeltasByDateByFile = {};
const dateSessions: Record<number, Session[]> = {};
$orderedSessionsFromLastFourDays.forEach((session) => {
$recentSessions.forEach((session) => {
const date = startOfDay(new Date(session.meta.startTimestampMs));
if (dateSessions[date.getTime()]) {
dateSessions[date.getTime()]?.push(session);
@ -74,13 +83,6 @@
});
}
let gitBranch = <string | undefined>undefined;
$: if ($project) {
getBranch({ projectId: $project?.id }).then((branch) => {
gitBranch = branch;
});
}
// convert a list of timestamps to a sparkline
function timestampsToSpark(tsArray: number[]) {
let range = tsArray[0] - tsArray[tsArray.length - 1];
@ -223,52 +225,47 @@
>
<div class="work-in-progress-container border-b border-zinc-700 py-4 px-4 ">
<h2 class="mb-2 text-lg font-bold text-zinc-300">Work in Progress</h2>
{#if gitBranch}
<div class="w-100 mb-4 flex items-center justify-between">
<div
class="button group flex max-w-[200px] justify-between rounded border border-zinc-600 bg-zinc-700 py-2 px-4 text-zinc-300 shadow"
>
<div class="h-4 w-4">
<svg
aria-hidden="true"
height="16"
viewBox="0 0 16 16"
version="1.1"
width="16"
data-view-component="true"
class="h-4 w-4 fill-zinc-400"
>
<path
d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"
/>
</svg>
</div>
<div
title={toHumanBranchName(gitBranch)}
class="truncate pl-2 font-mono text-zinc-300"
<div class="w-100 mb-4 flex items-center justify-between">
<div
class="button group flex max-w-[200px] justify-between rounded border border-zinc-600 bg-zinc-700 py-2 px-4 text-zinc-300 shadow"
>
<div class="h-4 w-4">
<svg
aria-hidden="true"
height="16"
viewBox="0 0 16 16"
version="1.1"
width="16"
data-view-component="true"
class="h-4 w-4 fill-zinc-400"
>
{toHumanBranchName(gitBranch)}
</div>
<div class="carrot flex hidden items-center pl-3">
<svg width="7" height="5" viewBox="0 0 7 5" fill="none" class="fill-zinc-400">
<path
d="M3.87796 4.56356C3.67858 4.79379 3.32142 4.79379 3.12204 4.56356L0.319371 1.32733C0.0389327 1.00351 0.268959 0.5 0.697336 0.5L6.30267 0.500001C6.73104 0.500001 6.96107 1.00351 6.68063 1.32733L3.87796 4.56356Z"
fill="#A1A1AA"
/>
</svg>
</div>
<path
d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"
/>
</svg>
</div>
<div>
<a
href="/projects/{$project?.id}/commit"
title="Commit changes"
class="button rounded bg-blue-600 py-2 px-3 text-white hover:bg-blue-700"
>Commit changes</a
>
<div title={$head} class="truncate pl-2 font-mono text-zinc-300">
{$head}
</div>
<div class="carrot flex hidden items-center pl-3">
<svg width="7" height="5" viewBox="0 0 7 5" fill="none" class="fill-zinc-400">
<path
d="M3.87796 4.56356C3.67858 4.79379 3.32142 4.79379 3.12204 4.56356L0.319371 1.32733C0.0389327 1.00351 0.268959 0.5 0.697336 0.5L6.30267 0.500001C6.73104 0.500001 6.96107 1.00351 6.68063 1.32733L3.87796 4.56356Z"
fill="#A1A1AA"
/>
</svg>
</div>
</div>
{/if}
{#if $filesStatus.length == 0}
<div>
<a
href="/projects/{$project?.id}/commit"
title="Commit changes"
class="button rounded bg-blue-600 py-2 px-3 text-white hover:bg-blue-700"
>Commit changes</a
>
</div>
</div>
{#if $statuses.length == 0}
<div
class="flex rounded border border-green-700 bg-green-900 p-4 align-middle text-green-400"
>
@ -284,21 +281,14 @@
Everything is committed
</div>
{:else}
<div class="rounded border border-yellow-400 bg-yellow-500 p-4 font-mono text-yellow-900">
<ul class="pl-4">
{#each $filesStatus as activity}
<li class="list-disc">
<div class="flex w-full gap-2 ">
{activity.status.slice(0, 1)}
<div
class="truncate"
use:collapsable={{ value: activity.path, separator: '/' }}
/>
</div>
</li>
{/each}
</ul>
</div>
<ul class="rounded border border-yellow-400 bg-yellow-500 p-4 font-mono text-yellow-900">
{#each $statuses as activity}
<li class="flex w-full gap-2">
<span class="font-semibold">{activity.status.slice(0, 1).toUpperCase()}</span>
<span class="truncate" use:collapsable={{ value: activity.path, separator: '/' }} />
</li>
{/each}
</ul>
{/if}
</div>
<div

View File

@ -1,20 +1,13 @@
import { building } from '$app/environment';
import { readable } from 'svelte/store';
import type { PageLoad } from './$types';
import { readable } from 'svelte/store';
import type { Activity } from '$lib/git/activity';
export const load: PageLoad = async () => {
const user = building
? {
...readable<undefined>(undefined),
set: () => {
throw new Error('not implemented');
},
delete: () => {
throw new Error('not implemented');
}
}
: await (await import('$lib/users')).default();
return {
user
};
export const load: PageLoad = async ({ params }) => {
const activity = building
? readable<Activity[]>([])
: await import('$lib/git/activity').then((m) => m.default({ projectId: params.projectId }));
return {
activity
};
};

View File

@ -5,13 +5,12 @@
import { collapsable } from '$lib/paths';
import toast from 'svelte-french-toast';
import { slide } from 'svelte/transition';
import { toHumanBranchName } from '$lib/branch';
import DiffViewer from '$lib/components/DiffViewer.svelte';
const api = Api({ fetch });
export let data: PageData;
const { project, user, filesStatus } = data;
const { project, user, statuses, head } = data;
let commitSubject: string;
let placeholderSubject = 'Summary (required)';
@ -37,7 +36,7 @@
message: commitSubject,
files: filesSelectedForCommit,
push: false
}).then((result) => {
}).then(() => {
toast.success('Commit successful!', {
icon: '🎉'
});
@ -55,7 +54,7 @@
filesSelectedForCommit = [];
};
const toggleAllOn = () => {
filesSelectedForCommit = $filesStatus.map((file) => {
filesSelectedForCommit = $statuses.map((file) => {
return file.path;
});
};
@ -65,11 +64,9 @@
const getDiff = (params: { projectId: string }) =>
invoke<Record<string, string>>('git_wd_diff', params);
const getBranch = (params: { projectId: string }) => invoke<string>('git_branch', params);
const getFile = (params: { projectId: string; path: string }) =>
invoke<string>('get_file_contents', params);
let gitBranch: string | undefined = undefined;
let gitDiff: Record<string, string> = {};
let generatedMessage: string | undefined = undefined;
let isLoaded = false;
@ -88,7 +85,7 @@
currentPath = path;
currentDiff = gitDiff[path];
} else {
let file = $filesStatus.filter((file) => file.path === path)[0];
let file = $statuses.filter((file) => file.path === path)[0];
if ($project && file) {
fileContentsStatus = file.status;
getFile({ projectId: $project.id, path: path }).then((contents) => {
@ -101,12 +98,6 @@
$: if ($project) {
if (!isLoaded) {
getBranch({ projectId: $project?.id }).then((branch) => {
gitBranch = branch;
filesSelectedForCommit = $filesStatus.map((file) => {
return file.path;
});
});
getDiff({ projectId: $project?.id }).then((diff) => {
gitDiff = diff;
});
@ -176,7 +167,7 @@
</svg>
</div>
<div class="truncate pl-2 font-mono text-zinc-300">
{toHumanBranchName(gitBranch)}
{$head}
</div>
<div class="carrot flex hidden items-center pl-3">
<svg width="7" height="5" viewBox="0 0 7 5" fill="none" class="fill-zinc-400">
@ -204,7 +195,7 @@
</div>
</div>
<ul class="min-h-[35px] truncate px-2 py-2">
{#each $filesStatus as activity}
{#each $statuses as activity}
<li class="list-none text-zinc-300">
<div class="flex flex-row align-middle">
<input

View File

@ -1,14 +0,0 @@
import { building } from '$app/environment';
import type { Session } from '$lib/sessions';
import { readable, type Readable } from 'svelte/store';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ params }) => {
const sessions: Readable<Session[]> = building
? readable<Session[]>([])
: await import('$lib/sessions').then((m) => m.default({ projectId: params.projectId }));
return {
sessions,
projectId: params.projectId
};
};