Improve git committing with new screen

New commit screen with diffs pulled from Rust and summarizable by gpt4.
This commit is contained in:
Scott Chacon 2023-03-20 15:12:22 +01:00
parent ffe56ab14c
commit 16601f31d3
4 changed files with 317 additions and 150 deletions

View File

@ -376,9 +376,14 @@ async fn git_status(
}
#[tauri::command]
async fn git_wd_diff(handle: tauri::AppHandle, project_id: &str) -> Result<String, Error> {
async fn git_wd_diff(
handle: tauri::AppHandle,
project_id: &str,
) -> Result<HashMap<String, String>, Error> {
let repo = repo_for_project(handle, project_id)?;
let diff = repo.wd_diff().with_context(|| "Failed to get git diff")?;
let diff = repo
.wd_diff(100) // max 100 lines per file
.with_context(|| "Failed to get git diff")?;
Ok(diff)
}

View File

@ -1,7 +1,7 @@
use crate::{deltas, fs, projects, sessions, users};
use anyhow::{Context, Result};
use git2::{BranchType, Cred, Signature};
use std::{collections::HashMap, env, path::Path, str::from_utf8};
use git2::{BranchType, Cred, DiffOptions, Signature};
use std::{collections::HashMap, env, path::Path};
use tauri::regex::Regex;
use walkdir::WalkDir;
@ -142,24 +142,56 @@ impl Repository {
Ok(branch.to_string())
}
pub fn wd_diff(&self) -> Result<String> {
println!("diffing");
pub fn wd_diff(&self, max_lines: usize) -> Result<HashMap<String, String>> {
let repo = &self.git_repository;
let head = repo.head()?;
let tree = head.peel_to_tree()?;
let diff = repo.diff_tree_to_workdir_with_index(Some(&tree), None)?;
let mut buf = String::new();
// Prepare our diff options based on the arguments given
let mut opts = DiffOptions::new();
opts.recurse_untracked_dirs(true)
.include_untracked(true)
.include_ignored(true);
let diff = repo.diff_tree_to_workdir(Some(&tree), Some(&mut opts))?;
let mut result = HashMap::new();
let mut results = String::new();
let mut current_line_count = 0;
let mut last_path = String::new();
diff.print(git2::DiffFormat::Patch, |delta, hunk, line| {
buf.push_str(&format!(
"{:?} {}",
delta.status(),
delta.new_file().path().unwrap().to_str().unwrap()
));
buf.push_str(from_utf8(line.content()).unwrap());
buf.push_str("\n");
let new_path = delta
.new_file()
.path()
.unwrap()
.to_str()
.unwrap()
.to_string();
print!(
"{} {}",
new_path,
std::str::from_utf8(line.content()).unwrap()
);
if new_path != last_path {
result.insert(last_path.clone(), results.clone());
results = String::new();
current_line_count = 0;
last_path = new_path.clone();
}
if current_line_count <= max_lines {
match line.origin() {
'+' | '-' | ' ' => results.push_str(&format!("{}", line.origin())),
_ => {}
}
results.push_str(&format!("{}", std::str::from_utf8(line.content()).unwrap()));
current_line_count += 1;
}
true
})?;
Ok(buf)
result.insert(last_path.clone(), results.clone());
Ok(result)
}
pub fn switch_branch(&self, branch_name: &str) -> Result<bool> {
@ -203,7 +235,12 @@ impl Repository {
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",
_ => continue,
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",
};
files.insert(path.to_string(), istatus.to_string());
}

View File

@ -1,49 +1,22 @@
<script lang="ts">
import type { LayoutData } from './$types';
import type { Readable } from 'svelte/store';
import type { Session } from '$lib/sessions';
import { format, startOfDay } from 'date-fns';
import type { Activity } from '$lib/sessions';
import type { Delta } from '$lib/deltas';
import { shortPath } from '$lib/paths';
import { invoke } from '@tauri-apps/api';
import { toHumanBranchName } from '$lib/branch';
import { list as listDeltas } from '$lib/deltas';
import { slide } from 'svelte/transition';
import { navigating } from '$app/stores';
import toast from 'svelte-french-toast';
import { goto } from '$app/navigation';
import Api from '$lib/api';
const api = Api({ fetch });
const getBranch = (params: { projectId: string }) => invoke<string>('git_branch', params);
const getDiff = (params: { projectId: string }) => invoke<string>('git_wd_diff', params);
export let data: LayoutData;
$: project = data.project;
$: filesStatus = data.filesStatus;
$: recentActivity = data.recentActivity;
$: orderedSessionsFromLastFourDays = data.orderedSessionsFromLastFourDays;
$: user = data.user;
const commit = (params: {
projectId: string;
message: string;
files: Array<string>;
push: boolean;
}) => invoke<boolean>('git_commit', params);
let latestDeltasByDateByFile: Record<number, Record<string, Delta[][]>[]> = {};
let commitMessage: string;
let placeholderMessage = 'Description of changes';
let initiatedCommit = false;
let filesSelectedForCommit: string[] = [];
$: if ($navigating) {
commitMessage = '';
filesSelectedForCommit = [];
initiatedCommit = false;
}
function gotoPlayer(filename: string) {
if ($project) {
@ -60,24 +33,6 @@
}
}
function doCommit() {
if ($project) {
commit({
projectId: $project.id,
message: commitMessage,
files: filesSelectedForCommit,
push: false
}).then((result) => {
toast.success('Commit successful!', {
icon: '🎉'
});
commitMessage = '';
filesSelectedForCommit = [];
initiatedCommit = false;
});
}
}
$: if ($project) {
latestDeltasByDateByFile = {};
const dateSessions: Record<number, Session[]> = {};
@ -188,27 +143,6 @@
return sessionsByFile;
}
function fetchCommitMessage() {
if ($project && $user) {
placeholderMessage = 'Summarizing changes...';
console.log('FETCHING DIFF');
getDiff({
projectId: $project.id
}).then((result) => {
console.log('DIFF', result);
api.summarize
.commit($user?.access_token, {
diff: result,
uid: $project.id
})
.then((result) => {
console.log(result);
commitMessage = result.message;
});
});
}
}
// order the sessions and summarize the changes by file
function orderedSessions(dateSessions: Record<number, Record<string, Delta[][]>[]>) {
return Object.entries(dateSessions)
@ -323,7 +257,13 @@
</svg>
</div>
</div>
<div class="branch-count-container text-md hidden hover:text-blue-500 ">6 branches</div>
<div>
<a
href="/projects/{$project?.id}/commit"
class="button rounded bg-blue-600 py-2 px-3 text-white hover:bg-blue-700"
>Commit changes</a
>
</div>
</div>
{/if}
{#if $filesStatus.length == 0}
@ -345,78 +285,13 @@
<div class="rounded border border-yellow-400 bg-yellow-500 p-4 font-mono text-yellow-900">
<ul class="w-80 truncate pl-4">
{#each $filesStatus as activity}
<li class={initiatedCommit ? '-ml-5' : 'list-disc'}>
{#if initiatedCommit}
<input
type="checkbox"
bind:group={filesSelectedForCommit}
value={activity.path}
/>
{/if}
<li class="list-disc">
{activity.status.slice(0, 1)}
{shortPath(activity.path)}
</li>
{/each}
</ul>
</div>
<!-- TODO: Button needs to be hooked up -->
<div class="mt-2 flex flex-col">
{#if initiatedCommit}
<div transition:slide={{ duration: 150 }}>
<h3 class="text-base font-semibold text-zinc-200">Commit Message</h3>
<textarea
rows="4"
name="message"
placeholder={placeholderMessage}
bind:value={commitMessage}
class="mb-2 block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:py-1.5 sm:text-sm sm:leading-6"
/>
</div>
{/if}
<div class="w-100 flex flex-row-reverse items-center justify-between gap-4">
{#if initiatedCommit}
<div class="flex gap-2">
<button
class="button w-[60px] rounded border border-zinc-600 py-2 text-white hover:bg-zinc-800"
on:click={() => {
initiatedCommit = false;
}}>✘</button
>
<button
disabled={!commitMessage || filesSelectedForCommit.length == 0}
class="{!commitMessage || filesSelectedForCommit.length == 0
? 'bg-zinc-800 text-zinc-600'
: ''} button rounded bg-blue-600 py-2 px-3 text-white"
on:click={() => {
doCommit();
initiatedCommit = false;
commitMessage = '';
}}>Commit changes</button
>
</div>
<div class="w-100 align-left">
{#if filesSelectedForCommit.length == 0}
<div>Select at least one file.</div>
{:else if !commitMessage}
<div>Provide a commit message.</div>
{:else}
<div>Are you certain of this?</div>
{/if}
</div>
{:else}
<button
class="button rounded bg-blue-600 py-2 px-3 text-white hover:bg-blue-700"
on:click={() => {
filesSelectedForCommit = $filesStatus.map((file) => {
return file.path;
});
fetchCommitMessage();
initiatedCommit = true;
}}>Commit changes</button
>
{/if}
</div>
</div>
{/if}
</div>
<div

View File

@ -0,0 +1,250 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api';
import type { PageData } from './$types';
import Api from '$lib/api';
import { shortPath } from '$lib/paths';
import toast from 'svelte-french-toast';
import { slide } from 'svelte/transition';
import { toHumanBranchName } from '$lib/branch';
const api = Api({ fetch });
export let data: PageData;
const { project, user, filesStatus } = data;
let commitSubject: string;
let placeholderSubject = 'One line summary of changes';
let commitMessage: string;
let placeholderMessage = 'Optional fuller description of changes';
let messageRows = 6;
let filesSelectedForCommit: string[] = [];
const commit = (params: {
projectId: string;
message: string;
files: Array<string>;
push: boolean;
}) => invoke<boolean>('git_commit', params);
function doCommit() {
if ($project) {
if (commitMessage) {
commitSubject = commitSubject + '\n\n' + commitMessage;
}
commit({
projectId: $project.id,
message: commitSubject,
files: filesSelectedForCommit,
push: false
}).then((result) => {
toast.success('Commit successful!', {
icon: '🎉'
});
commitMessage = '';
commitSubject = '';
filesSelectedForCommit = [];
isLoaded = false;
});
}
}
const toggleAllOff = () => {
filesSelectedForCommit = [];
};
const toggleAllOn = () => {
filesSelectedForCommit = $filesStatus.map((file) => {
return file.path;
});
};
const showMessage = (message: string) => {
generatedMessage = undefined;
};
const getDiff = (params: { projectId: string }) =>
invoke<Record<string, string>>('git_wd_diff', params);
const getBranch = (params: { projectId: string }) => invoke<string>('git_branch', params);
let gitBranch = <string | undefined>undefined;
let gitDiff = <string | undefined>undefined;
let generatedMessage = <string | undefined>undefined;
let isLoaded = false;
$: 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;
});
isLoaded = true;
}
}
let loadingPercent = 0;
function fetchCommitMessage() {
if ($project && $user) {
// make diff from keys of gitDiff matching entries in filesSelectedForCommit
const partialDiff = Object.fromEntries(
Object.entries(gitDiff).filter(([key]) => filesSelectedForCommit.includes(key))
);
console.log(partialDiff);
// convert to string
const diff = Object.values(partialDiff).join('\n').slice(0, 5000); // limit for summary
console.log(diff);
placeholderMessage = 'Summarizing changes...';
generatedMessage = 'loading';
loadingPercent = 0;
// every second update loadingPercent by 8%
const interval = setInterval(() => {
loadingPercent += 6.25;
if (loadingPercent >= 100) {
clearInterval(interval);
}
}, 1000);
api.summarize
.commit($user?.access_token, {
diff: diff,
uid: $project.id
})
.then((result) => {
if (result.message) {
// split result into subject and message (first line is subject)
commitSubject = result.message.split('\n')[0];
commitMessage = result.message.split('\n').slice(2).join('\n');
generatedMessage = result.message;
// set messageRows as a function of the number of chars in the message
messageRows = Math.ceil(commitMessage.length / 75) + 3;
}
loadingPercent = 100;
});
}
}
</script>
<div class="flex flex-row">
<div class="flex flex-col w-[500px] min-w-[500px] flex-shrink-0 p-2">
<div
class="button group mb-2 flex max-w-[500px] rounded border border-zinc-600 bg-zinc-700 py-2 px-4 text-zinc-300 shadow"
>
<div class="h-4 w-4">
<svg
text="gray"
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 class="truncate pl-2 font-mono text-zinc-300">
{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>
</div>
<div transition:slide={{ duration: 150 }}>
<h3 class="text-base font-semibold text-zinc-200 mb-2">Commit Message</h3>
<input
type="text"
name="subject"
bind:value={commitSubject}
placeholder={placeholderSubject}
class="mb-2 block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:py-1.5 sm:text-sm sm:leading-6"
/>
<textarea
rows={messageRows}
name="message"
placeholder={placeholderMessage}
bind:value={commitMessage}
class="mb-2 block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:py-1.5 sm:text-sm sm:leading-6"
/>
</div>
<div class="flex flex-row justify-between">
{#if filesSelectedForCommit.length == 0}
<div>Select at least one file.</div>
{:else if !commitSubject}
<div>Provide a commit message.</div>
{:else}
<button
disabled={!commitSubject || filesSelectedForCommit.length == 0}
class="{!commitSubject || filesSelectedForCommit.length == 0
? 'bg-zinc-800 text-zinc-600'
: ''} button rounded bg-blue-600 py-2 px-3 text-white"
on:click={() => {
doCommit();
}}>Commit changes</button
>
{/if}
{#if !generatedMessage}
<a class="cursor-pointer bg-green-800 rounded p-2" on:click={fetchCommitMessage}
>Generate a message for me.</a
>
{:else if generatedMessage == 'loading'}
<div class="flex flex-col">
<div class="text-zinc-400">Let me take a look at these changes...</div>
<!-- status bar filled by loadingPercent -->
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div
class="bg-green-600 h-2.5 rounded-full"
style="width: {Math.round(loadingPercent)}%"
/>
</div>
</div>
{/if}
</div>
<div class="mt-2">
<div class="rounded border border-zinc-400 bg-zinc-500 font-mono text-zinc-900">
<div
class="flex flex-row space-x-2 text-zinc-200 bg-zinc-800 rounded-t border-b border-zinc-600 p-2 mb-2"
>
<h3 class="text-base font-semibold ">File Changes</h3>
<a href="#" class="text-yellow-200" on:click={toggleAllOn}>all</a>
<a href="#" class="text-yellow-200" on:click={toggleAllOff}>none</a>
</div>
<ul class="w-80 truncate px-2 pb-2">
{#each $filesStatus as activity}
<li class="list-none">
<input
type="checkbox"
on:click={showMessage}
bind:group={filesSelectedForCommit}
value={activity.path}
/>
{activity.status.slice(0, 1)}
{shortPath(activity.path)}
</li>
{/each}
</ul>
</div>
</div>
</div>
<div class="flex-grow p-2 h-full max-h-screen overflow-auto">
{#if gitDiff}
{#each Object.entries(gitDiff) as [key, value]}
{#if key}
<div class="p-2">{key}</div>
<pre class="p-2 bg-zinc-900">{value}</pre>
{/if}
{/each}
{/if}
</div>
<!-- commit message -->
</div>