Welcome screen with recent repositories and a folder picker when gites starts outside a repository
This commit is contained in:
+43
-1
@@ -1,5 +1,5 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -130,3 +130,45 @@ pub fn load(dir: &Path) -> Result<RepoData, String> {
|
|||||||
|
|
||||||
Ok(RepoData { root, commits, refs, head_ref, head })
|
Ok(RepoData { root, commits, refs, head_ref, head })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The repository to open at start: the path given on the command line, or else the
|
||||||
|
/// current folder if it is inside a repository. `None` means gites starts with its
|
||||||
|
/// welcome screen. A path given on the command line is used even if it is not a
|
||||||
|
/// repository, so the window can say what is wrong with it.
|
||||||
|
pub fn start_dir(arg: Option<PathBuf>, cwd: PathBuf) -> Option<PathBuf> {
|
||||||
|
arg.or_else(|| git(&cwd, &["rev-parse", "--git-dir"]).ok().map(|_| cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("gites-start-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opens_the_path_given_on_the_command_line_even_if_it_is_not_a_repository() {
|
||||||
|
let given = PathBuf::from("/no/such/folder");
|
||||||
|
assert_eq!(start_dir(Some(given.clone()), temp_dir("arg")), Some(given));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opens_the_current_folder_when_it_is_inside_a_repository() {
|
||||||
|
let dir = temp_dir("repo");
|
||||||
|
git(&dir, &["init", "-q"]).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||||
|
assert_eq!(start_dir(None, dir.join("sub")), Some(dir.join("sub")));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opens_nothing_when_the_current_folder_is_not_in_a_repository() {
|
||||||
|
let dir = temp_dir("plain");
|
||||||
|
assert_eq!(start_dir(None, dir.clone()), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+17
-14
@@ -7,32 +7,36 @@ mod settings;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// The open repository folder. It starts as the first command line argument, or the
|
/// The open repository folder, from `git::start_dir`; it changes when the user opens
|
||||||
/// current folder, and changes when the user opens another one.
|
/// another one. `None` until a repository is open: the window shows its welcome screen.
|
||||||
struct RepoDir(Mutex<PathBuf>);
|
struct RepoDir(Mutex<Option<PathBuf>>);
|
||||||
|
|
||||||
impl RepoDir {
|
impl RepoDir {
|
||||||
fn get(&self) -> PathBuf {
|
fn get(&self) -> Result<PathBuf, String> {
|
||||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
self.0.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or_else(|| "no repository is open".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The open repository, or `None` when there is none yet.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn load_repo(dir: tauri::State<RepoDir>) -> Result<git::RepoData, String> {
|
fn load_repo(dir: tauri::State<RepoDir>) -> Result<Option<git::RepoData>, String> {
|
||||||
git::load(&dir.get())
|
match dir.get() {
|
||||||
|
Ok(d) => git::load(&d).map(Some),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to another repository. The current one stays open if `path` is not a repository.
|
/// Switches to another repository. The current one stays open if `path` is not a repository.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
|
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
|
||||||
let data = git::load(&PathBuf::from(&path))?;
|
let data = git::load(&PathBuf::from(&path))?;
|
||||||
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = PathBuf::from(&path);
|
*dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(PathBuf::from(&path));
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
|
fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
|
||||||
details::load(&dir.get(), &hash)
|
details::load(&dir.get()?, &hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -43,7 +47,7 @@ fn file_diff(
|
|||||||
old_path: Option<String>,
|
old_path: Option<String>,
|
||||||
force: bool,
|
force: bool,
|
||||||
) -> Result<diff::FileDiff, String> {
|
) -> Result<diff::FileDiff, String> {
|
||||||
diff::load(&dir.get(), &hash, &path, old_path.as_deref(), force)
|
diff::load(&dir.get()?, &hash, &path, old_path.as_deref(), force)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -68,10 +72,9 @@ fn save_settings(value: serde_json::Value) -> Result<(), String> {
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let path = std::env::args_os()
|
let arg = std::env::args_os().nth(1).map(PathBuf::from);
|
||||||
.nth(1)
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
.map(PathBuf::from)
|
let path = git::start_dir(arg, cwd);
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
|||||||
+15
-1
@@ -19,6 +19,7 @@ import LabelStack from "./components/LabelStack.vue";
|
|||||||
import PreviewPanel from "./components/PreviewPanel.vue";
|
import PreviewPanel from "./components/PreviewPanel.vue";
|
||||||
import RepoMenu from "./components/RepoMenu.vue";
|
import RepoMenu from "./components/RepoMenu.vue";
|
||||||
import Sidebar from "./components/Sidebar.vue";
|
import Sidebar from "./components/Sidebar.vue";
|
||||||
|
import Welcome from "./components/Welcome.vue";
|
||||||
|
|
||||||
const ROW_H = 28;
|
const ROW_H = 28;
|
||||||
const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif';
|
const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif';
|
||||||
@@ -106,17 +107,22 @@ function startDrag(e: PointerEvent, axis: "x" | "y") {
|
|||||||
let filtersLoadedFor: string | null = null;
|
let filtersLoadedFor: string | null = null;
|
||||||
|
|
||||||
let loading = false;
|
let loading = false;
|
||||||
|
/** False until the first load answers, so the welcome screen does not flash up at start. */
|
||||||
|
const started = ref(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
// The window gets focus often; one reload at a time is enough.
|
// The window gets focus often; one reload at a time is enough.
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
await applyRepo(await invoke<RepoData>("load_repo"));
|
// null: gites was started outside a repository and none is open yet.
|
||||||
|
const data = await invoke<RepoData | null>("load_repo");
|
||||||
|
if (data) await applyRepo(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = String(e);
|
error.value = String(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
|
started.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +542,14 @@ onUnmounted(() => {
|
|||||||
<button @click="menuOpen = true">Open a repository…</button>
|
<button @click="menuOpen = true">Open a repository…</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Welcome
|
||||||
|
v-else-if="!repo && started"
|
||||||
|
:recents="settings.recents"
|
||||||
|
@pick="openRepo"
|
||||||
|
@browse="browse"
|
||||||
|
@remove="(p) => (settings.recents = settings.recents.filter((x) => x !== p))"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else class="center">
|
<div v-else class="center">
|
||||||
|
|
||||||
<DiffView
|
<DiffView
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ recents: string[] }>();
|
||||||
|
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>();
|
||||||
|
|
||||||
|
// Paths on Windows use backslashes.
|
||||||
|
const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="welcome">
|
||||||
|
<div class="card">
|
||||||
|
<img src="/logo.svg" class="logo" alt="" />
|
||||||
|
<h2>Open a repository</h2>
|
||||||
|
<template v-if="recents.length">
|
||||||
|
<div class="head">Recent repositories</div>
|
||||||
|
<div class="list">
|
||||||
|
<div v-for="p in recents" :key="p" class="item">
|
||||||
|
<button class="open" :title="`open ${p}`" @click="emit('pick', p)">
|
||||||
|
<span class="name">{{ name(p) }}</span>
|
||||||
|
<span class="path">{{ p }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="x" title="remove from the list" @click="emit('remove', p)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button class="browse" @click="emit('browse')">Choose a folder…</button>
|
||||||
|
<p class="hint">You can also start gites with a path: <code>gites ~/code/my-project</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.welcome { flex: 1; min-width: 0; display: flex; align-items: flex-start; justify-content: center; overflow: auto; padding: 12vh 24px 24px; }
|
||||||
|
.card { width: min(560px, 100%); display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.logo { width: 112px; height: 112px; align-self: center; }
|
||||||
|
h2 { margin: 0 0 4px; font-size: 20px; }
|
||||||
|
.head { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); }
|
||||||
|
.list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 4px; background: var(--bg-side); }
|
||||||
|
.item { display: flex; align-items: center; border-radius: 6px; }
|
||||||
|
.item:hover, .item:focus-within { background: var(--bg-hover); }
|
||||||
|
.open { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 10px; border: none; padding: 7px 10px; text-align: left; background: none; }
|
||||||
|
.open:hover { background: none; }
|
||||||
|
.name { font-weight: 600; flex: none; }
|
||||||
|
.path { flex: 1; min-width: 0; color: var(--text-dim); font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.x { border: none; padding: 2px 10px; color: var(--text-dim); opacity: 0; }
|
||||||
|
.item:hover .x, .item:focus-within .x { opacity: 1; }
|
||||||
|
.browse { align-self: flex-start; padding: 6px 14px; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
|
||||||
|
.hint { margin: 4px 0 0; color: var(--text-dim); font-size: 12px; }
|
||||||
|
.hint code { font-family: ui-monospace, monospace; color: var(--text); }
|
||||||
|
button:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user