Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 70b144740681d82c
raw_hash: 2962395e6c4e618a
vouched: 2026-10-22T15:45:21Z
body_hash: 825caa43ddeedb08
raw_hash: a06c00e058bd3a88
vouched: 2026-10-22T16:18:52Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T04:08:49Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: 7148e9daddb2ba29
raw_hash: 1839c37c0e9b7635
vouched: 2026-10-22T14:33:27Z
body_hash: 3d892bdf2a5a0db7
raw_hash: f681a895851ad4fb
vouched: 2026-10-22T16:18:52Z
lines: 266-519
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T06:00:20Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: 7148e9daddb2ba29
raw_hash: 1839c37c0e9b7635
vouched: 2026-10-22T14:33:27Z
body_hash: 3d892bdf2a5a0db7
raw_hash: f681a895851ad4fb
vouched: 2026-10-22T16:18:52Z
lines: 281-561
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-19T21:09:57Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: 7148e9daddb2ba29
raw_hash: 1839c37c0e9b7635
vouched: 2026-10-22T14:33:27Z
body_hash: 3d892bdf2a5a0db7
raw_hash: f681a895851ad4fb
vouched: 2026-10-22T16:18:52Z
lines: 384-700
supersedes: 01M5APK9YCQH467YNMKAC8BT5B
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: d40ee870a4451138
raw_hash: 07401839125f7e0e
vouched: 2026-10-22T15:28:03Z
body_hash: 308268d80c20b68b
raw_hash: 417f8cf07e0e37ad
vouched: 2026-10-22T16:18:52Z
lines: 1-1153
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-22T14:00:47Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: d40ee870a4451138
raw_hash: 07401839125f7e0e
vouched: 2026-10-22T15:28:03Z
body_hash: 308268d80c20b68b
raw_hash: 417f8cf07e0e37ad
vouched: 2026-10-22T16:18:52Z
lines: 1-1309
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5J6GGQMK69MKRZ5RK9MS1BN
anchor: function found
created: 2026-10-22T16:18:36Z
norm: '1'
sig: 82ae12c684fe72fe
body_hash: 4e01c124fe60d876
raw_hash: 8456424d5a04b515
lines: 113-121
---

gh pr list --search returns [] for a repository GitHub can't find, so only gh api's 404 can tell an empty list from a missing repository
21 changes: 16 additions & 5 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { useCompute } from "./compute";
import { Dashboard } from "./dashboard";
import { DeleteChannel } from "./delete-channel";
import { Github, GithubSidebar } from "./github";
import { useGithubRefresh } from "./github-cache";
import { useGithub, useGithubRefresh } from "./github-cache";
import { desktop, here, titlebar } from "./desktop";
import { host } from "./host";
import { type Invite, parse } from "./invite";
Expand Down Expand Up @@ -312,7 +312,7 @@ export function App() {
const people = useSyncExternalStore(host.subscribe, () => host.people);
const presence = useSyncExternalStore(host.subscribe, () => host.presence);
const [hello, setHello] = useState<Hello>({ user: "", host: "" });
const [page, setPage] = useWindowStorage<Page>("ace:page", "dashboard");
const [saved, setPage] = useWindowStorage<Page>("ace:page", "dashboard");
const [project, setProject] = useWindowStorage<string | undefined>("ace:project-id", undefined);
const [all, setAll] = useWindowStorage("ace:all-projects", false);
const [selected, setSelected] = useWindowStorage<Record<string, string>>(
Expand Down Expand Up @@ -428,7 +428,6 @@ export function App() {
hello.host,
]);
useGithubRefresh(available);
const allProjects = all && page === "channels";
const listings = new Map(channels.map((channel) => [channel.id, channel]));
const byId = useMemo(() => {
const values = new Map<string, AppProject>();
Expand All @@ -440,6 +439,16 @@ export function App() {
return values;
}, [available]);
const current = (project && byId.get(project)) || available[0];
const repo = useGithub<string | null>(
current && { op: "project-repo", project: current.path, host: current.host },
);
// Only a definite answer hides GitHub, so the lists can still say why a lookup failed.
const linked = repo.value !== null;
const page = !linked && (saved === "issues" || saved === "prs") ? "channels" : saved;
function go(next: Page) {
if (linked || (next !== "issues" && next !== "prs")) setPage(next);
}
const allProjects = all && page === "channels";
const visible = current
? channels.filter((channel) => channelProject(channel) === current.id)
: [];
Expand Down Expand Up @@ -840,7 +849,8 @@ export function App() {
page={page}
user={hello.user}
github={people[hello.user]}
onPage={setPage}
repo={linked}
onPage={go}
onSettings={() => setSettings(true)}
/>
}
Expand All @@ -850,7 +860,7 @@ export function App() {
onOpen={() => void choose()}
onClone={cloneUrl}
onSettings={() => setSettings(true)}
onPage={setPage}
onPage={go}
connected={connected}
/>
{page !== "dashboard" && current && (
Expand Down Expand Up @@ -1086,6 +1096,7 @@ export function App() {
projects={available}
host={hello.host}
project={current?.id}
github={linked}
attention={attention}
onChannel={select}
onCreate={current && creatable && connected
Expand Down
56 changes: 31 additions & 25 deletions apps/app/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import { share } from "./window-storage";

export type Page = "dashboard" | "channels" | "issues" | "prs";

export function Navigation({ page, user, github, onPage, onSettings }: {
export function Navigation({ page, user, github, repo, onPage, onSettings }: {
page: Page;
user: string;
/** Shows the owner's GitHub avatar; initials without one. */
github?: string;
/** Whether the current project has a GitHub repository for Issues and Pull requests. */
repo: boolean;
onPage: (page: Page) => void;
onSettings: () => void;
}) {
Expand Down Expand Up @@ -55,30 +57,34 @@ export function Navigation({ page, user, github, onPage, onSettings }: {
>
Channels
</NavListLink>
<NavListLink
href="#issues"
icon={<IconIssue className="size-4" />}
active={page === "issues"}
shortcut="⌘3"
onClick={(event) => {
event.preventDefault();
onPage("issues");
}}
>
Issues
</NavListLink>
<NavListLink
href="#prs"
icon={<IconPullRequest className="size-4" />}
active={page === "prs"}
shortcut="⌘4"
onClick={(event) => {
event.preventDefault();
onPage("prs");
}}
>
PRs
</NavListLink>
{repo && (
<>
<NavListLink
href="#issues"
icon={<IconIssue className="size-4" />}
active={page === "issues"}
shortcut="⌘3"
onClick={(event) => {
event.preventDefault();
onPage("issues");
}}
>
Issues
</NavListLink>
<NavListLink
href="#prs"
icon={<IconPullRequest className="size-4" />}
active={page === "prs"}
shortcut="⌘4"
onClick={(event) => {
event.preventDefault();
onPage("prs");
}}
>
PRs
</NavListLink>
</>
)}
</NavList>
</Nav>
);
Expand Down
28 changes: 16 additions & 12 deletions apps/app/src/palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,21 @@ function tabItems(window: WindowState): CommandPaletteItem[] {
}

/** Mod+K searches channels in every project, the open channel's tabs, and window actions. */
export function Palette({ channels, projects, host, project, attention, onChannel, onCreate }: {
channels: Listing[];
projects: AppProject[];
host: string;
/** The current project's id. */
project?: string;
attention: Map<string, Attention>;
onChannel: (channel: Listing) => void;
/** Starts a channel in the current project, when one can start there. */
onCreate?: { name: string; run: () => void };
}) {
export function Palette(
{ channels, projects, host, project, github, attention, onChannel, onCreate }: {
channels: Listing[];
projects: AppProject[];
host: string;
/** The current project's id. */
project?: string;
/** Whether the current project has a GitHub repository for Issues and Pull requests. */
github: boolean;
attention: Map<string, Attention>;
onChannel: (channel: Listing) => void;
/** Starts a channel in the current project, when one can start there. */
onCreate?: { name: string; run: () => void };
},
) {
const [open, setOpen] = useState(false);
// Tabs are read once on open: they only change through the window the palette covers.
const [tabs, setTabs] = useState<CommandPaletteItem[]>([]);
Expand All @@ -119,7 +123,7 @@ export function Palette({ channels, projects, host, project, attention, onChanne
if (!open) return null;
const items: CommandPaletteItem[] = [
...(onCreate ? [action(CREATE, `New channel in ${onCreate.name}`, "Channel")] : []),
...ACTIONS,
...(github ? ACTIONS : ACTIONS.filter(({ id }) => id !== "issues" && id !== "prs")),
...tabs,
...channelItems(channels, projects, host, project, attention),
];
Expand Down
7 changes: 5 additions & 2 deletions apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,14 +642,15 @@ async function handle(
}
case "project-repo": {
if (request.host && request.host !== name && !client.peer) {
return (await remote(client, request.host)).request(
const repo = await (await remote(client, request.host)).request<string | null>(
{
op: "project-repo",
project: request.project,
},
undefined,
trace,
);
return repo && await github.found(repo) ? repo : null;
}
const known = client.peer
? (await Promise.all(catalog.list().map((channel) => projects.checkout(channel.project))))
Expand All @@ -661,7 +662,9 @@ async function handle(
if (client.peer && client.user !== catalog.user && !await access.canAccess(client.user, id)) {
throw new Error("You don't have access to this project");
}
return repo;
// A peer's host answers only what the remote names; GitHub reads stay on the asking host.
if (client.peer) return repo;
return repo && await github.found(repo) ? repo : null;
}
case "github-list":
return github.list(request);
Expand Down
15 changes: 15 additions & 0 deletions apps/host/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ export async function readable(repo: string, login?: string): Promise<boolean> {
return permission !== "none";
}

/**
* Whether GitHub finds `repo` for this host's gh account. It answers not found for private
* repositories the account can't read, and `gh pr list --search` returns nothing for them instead
* of failing. Other failures throw, so the lists can show them.
*/
export async function found(repo: string): Promise<boolean> {
return gh(["api", `repos/${repository(repo)}`, "--jq", ".id"]).then(
() => true,
(error: Error) => {
if (MISSING.test(error.message)) return false;
throw error;
},
);
}

/** A GitHub repository URL, SSH remote, or owner/name, as a valid owner/name. */
export function parse(value: string): string {
const repo = githubRepo(value);
Expand Down