Open on another machine from the window; keep the graph when a reload fails

This commit is contained in:
2026-10-15 15:15:41 +02:00
parent 16b0363d07
commit ccf945a067
3 changed files with 87 additions and 7 deletions
+3 -1
View File
@@ -116,7 +116,9 @@ async function load() {
const data = await invoke<RepoData | null>("load_repo"); const data = await invoke<RepoData | null>("load_repo");
if (data) await applyRepo(data); if (data) await applyRepo(data);
} catch (e) { } catch (e) {
error.value = String(e); // A repository already on screen stays, for example when a connection drops.
if (repo.value) showToast(`could not reload: ${e}`);
else error.value = String(e);
} finally { } finally {
loading = false; loading = false;
started.value = true; started.value = true;
+49 -5
View File
@@ -1,29 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, nextTick, onMounted, ref } from "vue";
const props = defineProps<{ recents: string[]; current: string | null }>(); const props = defineProps<{ recents: string[]; current: string | null }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>(); const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string]; close: [] }>();
// The current repository is not offered: picking it would do nothing. // The current repository is not offered: picking it would do nothing.
const items = computed(() => props.recents.filter((p) => p !== props.current)); const items = computed(() => props.recents.filter((p) => p !== props.current));
/** Index into items; items.length means the "Open folder…" entry. */ /** Index into items; items.length is the "Open folder…" entry and items.length + 1 the "Open on another machine…" entry. */
const active = ref(0); const active = ref(0);
const menu = ref<HTMLElement>(); const menu = ref<HTMLElement>();
onMounted(() => menu.value?.focus()); onMounted(() => menu.value?.focus());
/** True while the "Open on another machine…" entry is a text field. */
const typing = ref(false);
const remoteText = ref("");
const remoteInput = ref<HTMLInputElement>();
async function startTyping() {
typing.value = true;
await nextTick();
remoteInput.value?.focus();
}
function choose(i: number) { function choose(i: number) {
if (i >= items.value.length) emit("browse"); if (i === items.value.length + 1) void startTyping();
else if (i === items.value.length) emit("browse");
else emit("pick", items.value[i]); else emit("pick", items.value[i]);
} }
function onRemoteKey(e: KeyboardEvent) {
// Typed letters belong to the field, not to the menu's own keys.
e.stopPropagation();
if (e.key === "Enter") {
const text = remoteText.value.trim();
if (text) emit("pick", text);
} else if (e.key === "Escape") {
typing.value = false;
menu.value?.focus();
} else return;
e.preventDefault();
}
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
// Keys stay in the menu while it is open. // Keys stay in the menu while it is open.
e.stopPropagation(); e.stopPropagation();
const last = items.value.length; const last = items.value.length + 1;
if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1); if (e.key === "ArrowDown" || e.key === "j") active.value = Math.min(last, active.value + 1);
else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1); else if (e.key === "ArrowUp" || e.key === "k") active.value = Math.max(0, active.value - 1);
else if (e.key === "Enter") choose(active.value); else if (e.key === "Enter") choose(active.value);
else if (e.key === "Delete" && active.value < last) emit("remove", items.value[active.value]); else if (e.key === "Delete" && active.value < items.value.length) emit("remove", items.value[active.value]);
else if (e.key === "Escape" || e.key === "o") emit("close"); else if (e.key === "Escape" || e.key === "o") emit("close");
else return; else return;
e.preventDefault(); e.preventDefault();
@@ -57,6 +82,23 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
> >
Open folder… Open folder…
</div> </div>
<div
class="item remote"
:class="{ active: active === items.length + 1 }"
@mouseenter="active = items.length + 1"
@click="!typing && choose(items.length + 1)"
>
<input
v-if="typing"
ref="remoteInput"
v-model="remoteText"
class="remote-input"
placeholder="user@host:/path/to/repo"
spellcheck="false"
@keydown="onRemoteKey"
/>
<template v-else>Open on another machine…</template>
</div>
<div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div> <div class="foot"><kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Enter</kbd> open · <kbd>Del</kbd> remove · <kbd>Esc</kbd> close</div>
</div> </div>
</template> </template>
@@ -87,5 +129,7 @@ const name = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
.x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; } .x { border: none; padding: 0 6px; color: var(--text-dim); opacity: 0; }
.item.active .x { opacity: 1; } .item.active .x { opacity: 1; }
.browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; } .browse { color: var(--accent); border-top: 1px solid var(--border); border-radius: 0 0 6px 6px; margin-top: 4px; }
.remote { color: var(--accent); }
.remote-input { flex: 1; min-width: 0; font: 12px ui-monospace, monospace; }
.foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); } .foot { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); }
</style> </style>
+35 -1
View File
@@ -1,7 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref } from "vue";
defineProps<{ recents: string[] }>(); defineProps<{ recents: string[] }>();
const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>(); const emit = defineEmits<{ pick: [path: string]; browse: []; remove: [path: string] }>();
/** True while the "Open on another machine…" button is a text field. */
const typing = ref(false);
const remoteText = ref("");
const remoteInput = ref<HTMLInputElement>();
async function startTyping() {
typing.value = true;
await nextTick();
remoteInput.value?.focus();
}
function onRemoteKey(e: KeyboardEvent) {
if (e.key === "Enter") {
const text = remoteText.value.trim();
if (text) emit("pick", text);
} else if (e.key === "Escape") typing.value = false;
}
// Paths on Windows use backslashes. // Paths on Windows use backslashes.
const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p; const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
</script> </script>
@@ -23,7 +43,19 @@ const name = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
</div> </div>
</div> </div>
</template> </template>
<button class="browse" @click="emit('browse')">Choose a folder…</button> <div class="actions">
<button class="browse" @click="emit('browse')">Choose a folder…</button>
<input
v-if="typing"
ref="remoteInput"
v-model="remoteText"
class="remote-input"
placeholder="user@host:/path/to/repo"
spellcheck="false"
@keydown="onRemoteKey"
/>
<button v-else class="browse" @click="startTyping">Open on another machine…</button>
</div>
<p class="hint">You can also start gites with a path: <code>gites ~/code/my-project</code></p> <p class="hint">You can also start gites with a path: <code>gites ~/code/my-project</code></p>
</div> </div>
</div> </div>
@@ -44,6 +76,8 @@ h2 { margin: 0 0 4px; font-size: 20px; }
.path { flex: 1; min-width: 0; color: var(--text-dim); font: 11.5px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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; } .x { border: none; padding: 2px 10px; color: var(--text-dim); opacity: 0; }
.item:hover .x, .item:focus-within .x { opacity: 1; } .item:hover .x, .item:focus-within .x { opacity: 1; }
.actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.remote-input { flex: 1; min-width: 220px; font: 12px ui-monospace, monospace; }
.browse { align-self: flex-start; padding: 6px 14px; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } .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 { margin: 4px 0 0; color: var(--text-dim); font-size: 12px; }
.hint code { font-family: ui-monospace, monospace; color: var(--text); } .hint code { font-family: ui-monospace, monospace; color: var(--text); }