@hanzo/build 0.2.24 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api/changes.d.ts +22 -4
- package/lib/api/changes.js +16 -4
- package/lib/api/members.d.ts +3 -0
- package/lib/api/members.js +6 -0
- package/lib/api/platform.d.ts +8 -1
- package/lib/api/platform.js +7 -1
- package/lib/api/projects.d.ts +30 -4
- package/lib/api/projects.js +27 -4
- package/lib/api/turn.d.ts +6 -0
- package/lib/api/turn.js +9 -2
- package/lib/builder.js +7 -3
- package/lib/git.d.ts +12 -0
- package/lib/git.js +38 -4
- package/lib/host.d.ts +2 -0
- package/lib/project.js +73 -12
- package/lib/publish.d.ts +2 -1
- package/lib/publish.js +22 -5
- package/package.json +1 -1
package/lib/api/changes.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a run changed, read from the forge it pushed to, as the person asking
|
|
2
|
+
* What a run changed, read from the forge it pushed to, as the person asking,
|
|
3
|
+
* and its pull request merged there.
|
|
3
4
|
*
|
|
4
|
-
* GET
|
|
5
|
-
* GET
|
|
6
|
-
* GET
|
|
5
|
+
* GET /v1/agent/coding/{session}/changes commits base..head, each file's patch, the pull request
|
|
6
|
+
* GET /v1/agent/coding/{session}/tree?path= one directory at the run's branch (its base until it pushes)
|
|
7
|
+
* GET /v1/agent/coding/{session}/blob?path= one file there, in /v1/git's blob shape
|
|
8
|
+
* POST /v1/agent/coding/{session}/merge → {number, url, state, base}; 409 with the forge's reason
|
|
7
9
|
*
|
|
8
10
|
* Before the run pushes, changes answers empty, not 404.
|
|
9
11
|
*/
|
|
@@ -54,3 +56,19 @@ export declare function changes(raw: unknown): Changes;
|
|
|
54
56
|
export declare function read(t: Target, session: string): Promise<Changes>;
|
|
55
57
|
export declare function tree(t: Target, session: string, path?: string): Promise<Entry[]>;
|
|
56
58
|
export declare function blob(t: Target, session: string, path: string): Promise<Blob>;
|
|
59
|
+
/** A pull request after its merge. */
|
|
60
|
+
export interface Merged {
|
|
61
|
+
number: number;
|
|
62
|
+
url: string;
|
|
63
|
+
/** `merged`. */
|
|
64
|
+
state: string;
|
|
65
|
+
/** The branch it was merged into. */
|
|
66
|
+
base: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Merge the run's pull request into its base, at the commit the forge holds
|
|
70
|
+
* now: a push after that is refused, not merged unseen. One already merged
|
|
71
|
+
* answers as it is; a conflict, a closed pull request or a rule on the base is
|
|
72
|
+
* a 409 carrying the forge's reason. Nothing is forced.
|
|
73
|
+
*/
|
|
74
|
+
export declare function merge(t: Target, session: string): Promise<Merged>;
|
package/lib/api/changes.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a run changed, read from the forge it pushed to, as the person asking
|
|
2
|
+
* What a run changed, read from the forge it pushed to, as the person asking,
|
|
3
|
+
* and its pull request merged there.
|
|
3
4
|
*
|
|
4
|
-
* GET
|
|
5
|
-
* GET
|
|
6
|
-
* GET
|
|
5
|
+
* GET /v1/agent/coding/{session}/changes commits base..head, each file's patch, the pull request
|
|
6
|
+
* GET /v1/agent/coding/{session}/tree?path= one directory at the run's branch (its base until it pushes)
|
|
7
|
+
* GET /v1/agent/coding/{session}/blob?path= one file there, in /v1/git's blob shape
|
|
8
|
+
* POST /v1/agent/coding/{session}/merge → {number, url, state, base}; 409 with the forge's reason
|
|
7
9
|
*
|
|
8
10
|
* Before the run pushes, changes answers empty, not 404.
|
|
9
11
|
*/
|
|
@@ -61,3 +63,13 @@ export async function tree(t, session, path = '') {
|
|
|
61
63
|
export async function blob(t, session, path) {
|
|
62
64
|
return file(await call(t, 'GET', `/v1/agent/coding/${seg(session)}/blob${query({ path })}`), path);
|
|
63
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Merge the run's pull request into its base, at the commit the forge holds
|
|
68
|
+
* now: a push after that is refused, not merged unseen. One already merged
|
|
69
|
+
* answers as it is; a conflict, a closed pull request or a rule on the base is
|
|
70
|
+
* a 409 carrying the forge's reason. Nothing is forced.
|
|
71
|
+
*/
|
|
72
|
+
export async function merge(t, session) {
|
|
73
|
+
const o = obj(await call(t, 'POST', `/v1/agent/coding/${seg(session)}/merge`, {}));
|
|
74
|
+
return { number: num(o.number), url: str(o.url), state: str(o.state), base: str(o.base) };
|
|
75
|
+
}
|
package/lib/api/members.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* GET /v1/iam/invitations?owner= {invitations, total} (org admin)
|
|
7
7
|
* POST /v1/iam/invitations issue one (org admin)
|
|
8
8
|
* DELETE /v1/iam/invitations/{owner}/{name} withdraw one (org admin)
|
|
9
|
+
* GET /v1/iam/organizations/admin/{org} the org's record, whose `logo` is its mark (member)
|
|
9
10
|
*
|
|
10
11
|
* IAM decides the scope from the credential: a member reads the roster of an org
|
|
11
12
|
* they belong to, and only an admin of the org reads or writes its invitations.
|
|
@@ -45,3 +46,5 @@ export declare const EMAIL: RegExp;
|
|
|
45
46
|
/** Invite one address into the org: one seat, redeemable now, pinned to that address. */
|
|
46
47
|
export declare function invite(t: Target, org: string, email: string): Promise<Invitation>;
|
|
47
48
|
export declare function revoke(t: Target, i: Pick<Invitation, 'owner' | 'name'>): Promise<void>;
|
|
49
|
+
/** The org's own logo address as IAM holds it, or '' when it has none. */
|
|
50
|
+
export declare function logo(t: Target, org: string): Promise<string>;
|
package/lib/api/members.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* GET /v1/iam/invitations?owner= {invitations, total} (org admin)
|
|
7
7
|
* POST /v1/iam/invitations issue one (org admin)
|
|
8
8
|
* DELETE /v1/iam/invitations/{owner}/{name} withdraw one (org admin)
|
|
9
|
+
* GET /v1/iam/organizations/admin/{org} the org's record, whose `logo` is its mark (member)
|
|
9
10
|
*
|
|
10
11
|
* IAM decides the scope from the credential: a member reads the roster of an org
|
|
11
12
|
* they belong to, and only an admin of the org reads or writes its invitations.
|
|
@@ -86,3 +87,8 @@ export async function invite(t, org, email) {
|
|
|
86
87
|
export async function revoke(t, i) {
|
|
87
88
|
await call(t, 'DELETE', `/v1/iam/invitations/${seg(i.owner)}/${seg(i.name)}`);
|
|
88
89
|
}
|
|
90
|
+
/** The org's own logo address as IAM holds it, or '' when it has none. */
|
|
91
|
+
export async function logo(t, org) {
|
|
92
|
+
const raw = obj(await call(t, 'GET', `/v1/iam/organizations/admin/${encodeURIComponent(org)}`));
|
|
93
|
+
return str(obj(raw.data ?? raw).logo);
|
|
94
|
+
}
|
package/lib/api/platform.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Adding a repository to a project, which is how a project is published.
|
|
3
3
|
*
|
|
4
4
|
* POST /v1/platform/apps {repo, ref, name, partOf, mode, tag?}
|
|
5
|
-
* → 202 {app, build?: {id, job, image, status}, declaration: {mode, ref, review, live, …}}
|
|
5
|
+
* → 202 {app, build?: {id, job, image, status}, declaration: {mode, ref, review, live, …}, notice?}
|
|
6
6
|
* GET /v1/platform/builds → {builds: [{id, repo, commit, status, startedAt, duration}]}
|
|
7
7
|
*
|
|
8
8
|
* The declaration is a values file in universe. `branch` (the default) opens a
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* refuses to name an image that is not built yet, so publishing to main is two
|
|
11
11
|
* calls: build on a branch, then commit that tag once the build is green. An
|
|
12
12
|
* org admin gets both; anyone else gets the review link.
|
|
13
|
+
*
|
|
14
|
+
* A repository in the org's own code workspace is its project's site instead:
|
|
15
|
+
* built in a sandbox and served at the project's address when the build
|
|
16
|
+
* finishes. That answer is `declaration.live` with a `notice` saying so, and no
|
|
17
|
+
* review; its build is on the same board, building, succeeded or failed.
|
|
13
18
|
*/
|
|
14
19
|
import { type Target } from './call.ts';
|
|
15
20
|
export interface Declare {
|
|
@@ -39,6 +44,8 @@ export interface Declared {
|
|
|
39
44
|
review: string;
|
|
40
45
|
/** Whether anything can deploy from this write. */
|
|
41
46
|
live: boolean;
|
|
47
|
+
/** What the platform says of this write in a sentence, or ''. */
|
|
48
|
+
notice: string;
|
|
42
49
|
}
|
|
43
50
|
export interface Build {
|
|
44
51
|
id: string;
|
package/lib/api/platform.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Adding a repository to a project, which is how a project is published.
|
|
3
3
|
*
|
|
4
4
|
* POST /v1/platform/apps {repo, ref, name, partOf, mode, tag?}
|
|
5
|
-
* → 202 {app, build?: {id, job, image, status}, declaration: {mode, ref, review, live, …}}
|
|
5
|
+
* → 202 {app, build?: {id, job, image, status}, declaration: {mode, ref, review, live, …}, notice?}
|
|
6
6
|
* GET /v1/platform/builds → {builds: [{id, repo, commit, status, startedAt, duration}]}
|
|
7
7
|
*
|
|
8
8
|
* The declaration is a values file in universe. `branch` (the default) opens a
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* refuses to name an image that is not built yet, so publishing to main is two
|
|
11
11
|
* calls: build on a branch, then commit that tag once the build is green. An
|
|
12
12
|
* org admin gets both; anyone else gets the review link.
|
|
13
|
+
*
|
|
14
|
+
* A repository in the org's own code workspace is its project's site instead:
|
|
15
|
+
* built in a sandbox and served at the project's address when the build
|
|
16
|
+
* finishes. That answer is `declaration.live` with a `notice` saying so, and no
|
|
17
|
+
* review; its build is on the same board, building, succeeded or failed.
|
|
13
18
|
*/
|
|
14
19
|
import { call } from './call.js';
|
|
15
20
|
const str = (v) => (typeof v === 'string' ? v : '');
|
|
@@ -49,6 +54,7 @@ export async function declare(t, d) {
|
|
|
49
54
|
ref: str(dec.ref),
|
|
50
55
|
review: https(str(dec.review)),
|
|
51
56
|
live: dec.live === true,
|
|
57
|
+
notice: str(raw.notice),
|
|
52
58
|
};
|
|
53
59
|
}
|
|
54
60
|
export async function builds(t) {
|
package/lib/api/projects.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The org's projects: what it has built, and where each one is served.
|
|
3
3
|
*
|
|
4
|
-
* GET /v1/projects
|
|
5
|
-
* PATCH /v1/projects/{slug}
|
|
6
|
-
* DELETE /v1/projects/{slug}
|
|
4
|
+
* GET /v1/projects → Project[]
|
|
5
|
+
* PATCH /v1/projects/{slug} rename it, or make it public or private
|
|
6
|
+
* DELETE /v1/projects/{slug} delete it and take its site down
|
|
7
|
+
* GET /v1/projects/{slug}/deployments → Deployment[], newest first
|
|
7
8
|
*
|
|
8
9
|
* `slug` is the one key every surface shares — the address under /dev, the
|
|
9
10
|
* site's name, the `project` a run is tagged with. A project belongs to the org,
|
|
@@ -19,6 +20,11 @@ export interface Project {
|
|
|
19
20
|
repo: string;
|
|
20
21
|
/** The branch pushes to which rebuild it, or ''. */
|
|
21
22
|
branch: string;
|
|
23
|
+
/**
|
|
24
|
+
* Where it stands: `building` while a project that has never served is being
|
|
25
|
+
* published, `live` once it serves, `error` when its build failed. A live
|
|
26
|
+
* project stays `live` while it is built again.
|
|
27
|
+
*/
|
|
22
28
|
status: string;
|
|
23
29
|
/** The deployed address, or '' when nothing has shipped. */
|
|
24
30
|
live: string;
|
|
@@ -42,6 +48,24 @@ export declare function change(t: Target, slug: string, what: {
|
|
|
42
48
|
}): Promise<Project>;
|
|
43
49
|
/** Delete a project. Its site stops answering and its slug is free again. */
|
|
44
50
|
export declare function remove(t: Target, slug: string): Promise<void>;
|
|
51
|
+
/** One attempt to put a project on its address, as the platform recorded it. */
|
|
52
|
+
export interface Deployment {
|
|
53
|
+
id: string;
|
|
54
|
+
/** Counts the project's deployments from 1. */
|
|
55
|
+
version: number;
|
|
56
|
+
/** `building`, `live` or `error`, as the platform said. */
|
|
57
|
+
status: string;
|
|
58
|
+
/** The revision that was built, or ''. */
|
|
59
|
+
commit: string;
|
|
60
|
+
/** What happened, in words: the build's own note, or on a failure why it failed. */
|
|
61
|
+
message: string;
|
|
62
|
+
/** Unix seconds; 0 when unknown. */
|
|
63
|
+
created: number;
|
|
64
|
+
updated: number;
|
|
65
|
+
}
|
|
66
|
+
export declare function deployment(raw: unknown): Deployment;
|
|
67
|
+
/** A project's deployments, newest first: the first says how its latest build went. */
|
|
68
|
+
export declare function deployments(t: Target, slug: string): Promise<Deployment[]>;
|
|
45
69
|
/** The repository name a project's clone URL names — the last path segment, without `.git`. */
|
|
46
70
|
export declare function name(clone: string): string;
|
|
47
71
|
/** A starter from the public catalog. */
|
|
@@ -60,7 +84,9 @@ export interface Template {
|
|
|
60
84
|
export declare function templates(t: Target): Promise<Template[]>;
|
|
61
85
|
/**
|
|
62
86
|
* Take a copy of a starter: a project whose repository is the starter's own,
|
|
63
|
-
* so what opens is that template rather than a model's imitation of it.
|
|
87
|
+
* so what opens is that template rather than a model's imitation of it. In an
|
|
88
|
+
* org whose code is in a workspace the platform made, the copy is published at
|
|
89
|
+
* once, and answers `building` until it is `live` or its build failed.
|
|
64
90
|
*/
|
|
65
91
|
export declare function fork(t: Target, slug: string): Promise<Project>;
|
|
66
92
|
/** `owner/name` from a clone URL — its last two path segments, without `.git`. */
|
package/lib/api/projects.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The org's projects: what it has built, and where each one is served.
|
|
3
3
|
*
|
|
4
|
-
* GET /v1/projects
|
|
5
|
-
* PATCH /v1/projects/{slug}
|
|
6
|
-
* DELETE /v1/projects/{slug}
|
|
4
|
+
* GET /v1/projects → Project[]
|
|
5
|
+
* PATCH /v1/projects/{slug} rename it, or make it public or private
|
|
6
|
+
* DELETE /v1/projects/{slug} delete it and take its site down
|
|
7
|
+
* GET /v1/projects/{slug}/deployments → Deployment[], newest first
|
|
7
8
|
*
|
|
8
9
|
* `slug` is the one key every surface shares — the address under /dev, the
|
|
9
10
|
* site's name, the `project` a run is tagged with. A project belongs to the org,
|
|
@@ -74,6 +75,26 @@ export async function change(t, slug, what) {
|
|
|
74
75
|
export async function remove(t, slug) {
|
|
75
76
|
await call(t, 'DELETE', `/v1/projects/${seg(slug)}`);
|
|
76
77
|
}
|
|
78
|
+
export function deployment(raw) {
|
|
79
|
+
const d = (raw && typeof raw === 'object' ? raw : {});
|
|
80
|
+
return {
|
|
81
|
+
id: str(d.id),
|
|
82
|
+
version: num(d.version),
|
|
83
|
+
status: str(d.status),
|
|
84
|
+
commit: str(d.commit),
|
|
85
|
+
message: str(d.message),
|
|
86
|
+
created: num(d.createdAt),
|
|
87
|
+
updated: num(d.updatedAt),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** A project's deployments, newest first: the first says how its latest build went. */
|
|
91
|
+
export async function deployments(t, slug) {
|
|
92
|
+
const raw = await call(t, 'GET', `/v1/projects/${seg(slug)}/deployments`);
|
|
93
|
+
return (Array.isArray(raw) ? raw : [])
|
|
94
|
+
.map(deployment)
|
|
95
|
+
.filter((d) => d.id)
|
|
96
|
+
.sort((a, b) => b.version - a.version);
|
|
97
|
+
}
|
|
77
98
|
/** The repository name a project's clone URL names — the last path segment, without `.git`. */
|
|
78
99
|
export function name(clone) {
|
|
79
100
|
const cut = clone.replace(/\.git$/, '').replace(/\/+$/, '');
|
|
@@ -100,7 +121,9 @@ export async function templates(t) {
|
|
|
100
121
|
}
|
|
101
122
|
/**
|
|
102
123
|
* Take a copy of a starter: a project whose repository is the starter's own,
|
|
103
|
-
* so what opens is that template rather than a model's imitation of it.
|
|
124
|
+
* so what opens is that template rather than a model's imitation of it. In an
|
|
125
|
+
* org whose code is in a workspace the platform made, the copy is published at
|
|
126
|
+
* once, and answers `building` until it is `live` or its build failed.
|
|
104
127
|
*/
|
|
105
128
|
export async function fork(t, slug) {
|
|
106
129
|
return project(await call(t, 'POST', '/v1/projects/fork', { slug }));
|
package/lib/api/turn.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface Outcome {
|
|
|
39
39
|
* never put on screen as the reason.
|
|
40
40
|
*/
|
|
41
41
|
problem: string;
|
|
42
|
+
/**
|
|
43
|
+
* The place in the run (its `seq`) of the latest status that said its work
|
|
44
|
+
* went live on its project's site, or 0. It is the cue to read the project
|
|
45
|
+
* again; the address shown is the project's own, never one an event names.
|
|
46
|
+
*/
|
|
47
|
+
published: number;
|
|
42
48
|
}
|
|
43
49
|
/**
|
|
44
50
|
* What the run narrated about how it ended, latest winning. Status text only:
|
package/lib/api/turn.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* The coding plane's vocabulary (apps/coding): `status` for a lifecycle move
|
|
13
13
|
* (started, routed, done, error, stopped, paused), `tool-call` for a step,
|
|
14
14
|
* `log` for a free line. A person's steering is a `control` {command, message}.
|
|
15
|
+
* A run on a project with a site ends `done` with `live` when it published what
|
|
16
|
+
* it pushed, or `unpublished` saying why it did not.
|
|
15
17
|
*
|
|
16
18
|
* `cards` is the transcript as it is drawn: the run's steps, and the agent's own
|
|
17
19
|
* output read by what each part is (harness.ts). The output a card shows is what
|
|
@@ -62,7 +64,10 @@ function status(b, mode) {
|
|
|
62
64
|
return 'Done — nothing to change';
|
|
63
65
|
const pr = str(b.pr);
|
|
64
66
|
const tail = str(b.prError) ? ' — the pull request could not be opened' : pr ? ` — ${pr}` : '';
|
|
65
|
-
|
|
67
|
+
// A run on a project with a site publishes what it pushed, or says why not.
|
|
68
|
+
const why = str(b.unpublished);
|
|
69
|
+
const site = why ? ` — not published: ${why}` : str(b.live) ? ' — published' : '';
|
|
70
|
+
return branch ? `Pushed ${branch}${tail}${site}` : `Done${tail}${site}`;
|
|
66
71
|
}
|
|
67
72
|
case 'error':
|
|
68
73
|
return str(b.error) || 'The run hit an error';
|
|
@@ -175,7 +180,7 @@ export function steps(events) {
|
|
|
175
180
|
* the branch and the pull request are read from the run's record (`pull`).
|
|
176
181
|
*/
|
|
177
182
|
export function outcome(events) {
|
|
178
|
-
const out = { status: '', problem: '' };
|
|
183
|
+
const out = { status: '', problem: '', published: 0 };
|
|
179
184
|
for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
|
|
180
185
|
if (e.kind !== 'status')
|
|
181
186
|
continue;
|
|
@@ -184,6 +189,8 @@ export function outcome(events) {
|
|
|
184
189
|
continue;
|
|
185
190
|
out.status = str(b.status) || out.status;
|
|
186
191
|
out.problem = str(b.prError) || out.problem;
|
|
192
|
+
if (str(b.live))
|
|
193
|
+
out.published = e.seq;
|
|
187
194
|
}
|
|
188
195
|
return out;
|
|
189
196
|
}
|
package/lib/builder.js
CHANGED
|
@@ -33,6 +33,7 @@ import { Button } from '@hanzo/ui';
|
|
|
33
33
|
import { SessionRail } from '@hanzo/ui/chat';
|
|
34
34
|
import { HanzoMark } from '@hanzo/ui/product';
|
|
35
35
|
import { useMemo, useState } from 'react';
|
|
36
|
+
import { safe } from './api/projects.js';
|
|
36
37
|
import { pinBoard } from './choice.js';
|
|
37
38
|
import { Customize } from './customize/index.js';
|
|
38
39
|
import { useKept, useRecents } from './data.js';
|
|
@@ -85,7 +86,7 @@ function Shell() {
|
|
|
85
86
|
who.toggle();
|
|
86
87
|
},
|
|
87
88
|
}
|
|
88
|
-
: { name: 'Sign in', onPress: () => host.signIn?.() }, collapsed: collapsed, onCollapse: setCollapsed, mark: _jsx(Brand, { org: host.org }), pt: collapsed ? undefined : 92, open: drawer, onOpenChange: setDrawer, label: "Runs" }), collapsed ? null : (_jsxs(YStack, { position: "absolute", t: 10, l: 8, r: 8, z: 2, gap: "$2.5", "$max-md": { display: 'none' }, children: [_jsx(XStack, { px: "$2.5", children: name }), _jsxs(XStack, { render: "button", "aria-label": "Search runs", onPress: () => setFinding(true), items: "center", gap: "$2", px: "$2.5", height: 34, rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", hoverStyle: { bg: '$hover' }, children: [_jsx(Search, { size: 15, color: "$soft" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Search" })] })] })), host.person ? _jsx(Who, { open: who.open, onOpenChange: who.onOpenChange, narrow: collapsed }) : null] }), _jsxs(YStack, { flex: 1, minW: 0, minH: 0, position: "relative", children: [_jsxs(XStack, { height: 44, px: "$2", gap: "$2", items: "center", shrink: 0, $md: { display: 'none' }, children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onPress: () => setDrawer(true), "aria-label": "Open runs", children: _jsx(Menu, { size: 18 }) }), name, _jsx(XStack, { flex: 1 }), _jsx(Button, { variant: "ghost", size: "icon-sm", onPress: () => setFinding(true), "aria-label": "Search runs", children: _jsx(Search, { size: 18 }) })] }), _jsx(Pane, { onStarted: () => recents.reload() })] }), _jsx(Find, { open: finding, onOpenChange: setFinding, recents: rows, onOpen: (id) => { setFinding(false); go(id); } })] }));
|
|
89
|
+
: { name: 'Sign in', onPress: () => host.signIn?.() }, collapsed: collapsed, onCollapse: setCollapsed, mark: _jsx(Brand, { org: host.org, logo: host.logo }), pt: collapsed ? undefined : 92, open: drawer, onOpenChange: setDrawer, label: "Runs" }), collapsed ? null : (_jsxs(YStack, { position: "absolute", t: 10, l: 8, r: 8, z: 2, gap: "$2.5", "$max-md": { display: 'none' }, children: [_jsx(XStack, { px: "$2.5", children: name }), _jsxs(XStack, { render: "button", "aria-label": "Search runs", onPress: () => setFinding(true), items: "center", gap: "$2", px: "$2.5", height: 34, rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", hoverStyle: { bg: '$hover' }, children: [_jsx(Search, { size: 15, color: "$soft" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Search" })] })] })), host.person ? _jsx(Who, { open: who.open, onOpenChange: who.onOpenChange, narrow: collapsed }) : null] }), _jsxs(YStack, { flex: 1, minW: 0, minH: 0, position: "relative", children: [_jsxs(XStack, { height: 44, px: "$2", gap: "$2", items: "center", shrink: 0, $md: { display: 'none' }, children: [_jsx(Button, { variant: "ghost", size: "icon-sm", onPress: () => setDrawer(true), "aria-label": "Open runs", children: _jsx(Menu, { size: 18 }) }), name, _jsx(XStack, { flex: 1 }), _jsx(Button, { variant: "ghost", size: "icon-sm", onPress: () => setFinding(true), "aria-label": "Search runs", children: _jsx(Search, { size: 18 }) })] }), _jsx(Pane, { onStarted: () => recents.reload() })] }), _jsx(Find, { open: finding, onOpenChange: setFinding, recents: rows, onOpen: (id) => { setFinding(false); go(id); } })] }));
|
|
89
90
|
}
|
|
90
91
|
/**
|
|
91
92
|
* The builder's places, as a rail draws them: four in view and the rest under
|
|
@@ -154,8 +155,11 @@ function Pane({ onStarted }) {
|
|
|
154
155
|
host.go(id);
|
|
155
156
|
} }));
|
|
156
157
|
}
|
|
157
|
-
/** The Hanzo mark
|
|
158
|
-
function Brand({ org }) {
|
|
158
|
+
/** The org's mark: the logo it chose; else the Hanzo mark for Hanzo or a personal account, and an organization's initial. */
|
|
159
|
+
function Brand({ org, logo }) {
|
|
160
|
+
const chosen = logo ? safe(logo) : '';
|
|
161
|
+
if (chosen)
|
|
162
|
+
return _jsx("img", { src: chosen, alt: "", width: 18, height: 18, style: { borderRadius: 4, objectFit: 'cover' } });
|
|
159
163
|
if (!org || org === 'hanzo')
|
|
160
164
|
return _jsx(HanzoMark, { size: 18 });
|
|
161
165
|
const letter = org.charAt(0).toUpperCase();
|
package/lib/git.d.ts
CHANGED
|
@@ -3,6 +3,18 @@ export declare function Git({ session, title, live }: {
|
|
|
3
3
|
title: string;
|
|
4
4
|
live: boolean;
|
|
5
5
|
}): import("react").JSX.Element;
|
|
6
|
+
/**
|
|
7
|
+
* Merge for a run's pull request, shown while it is open: `POST …/merge` lands
|
|
8
|
+
* the run's branch in its base at the commit the forge holds now. One press is
|
|
9
|
+
* one merge — pressed again while it is out, nothing more is sent — and a
|
|
10
|
+
* refusal says the forge's reason. `onMerged` reads the run again, which is
|
|
11
|
+
* what closes the button: the pull request reads merged.
|
|
12
|
+
*/
|
|
13
|
+
export declare function Merge({ session, open, onMerged }: {
|
|
14
|
+
session: string;
|
|
15
|
+
open: boolean;
|
|
16
|
+
onMerged: () => void;
|
|
17
|
+
}): import("react").JSX.Element | null;
|
|
6
18
|
/** A unified diff's lines, coloured by what each does: the Git tab's, and a transcript's edit card's. */
|
|
7
19
|
export declare function Patch({ text }: {
|
|
8
20
|
text: string;
|
package/lib/git.js
CHANGED
|
@@ -2,13 +2,15 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
/**
|
|
3
3
|
* A run's Git tab: what it pushed, read from the forge. Diff is the net change
|
|
4
4
|
* from its base, Review is the pull request it opened and what reviewers said,
|
|
5
|
-
* Commits is its branch's history since the base.
|
|
6
|
-
* read again every little while, because a run
|
|
5
|
+
* with Merge while it is open, Commits is its branch's history since the base.
|
|
6
|
+
* While the run works it is read again every little while, because a run
|
|
7
|
+
* pushes when it finishes a step.
|
|
7
8
|
*/
|
|
8
9
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
9
10
|
import { ChevronDown, ChevronRight } from '@hanzogui/lucide-icons-2';
|
|
11
|
+
import { Button } from '@hanzo/ui';
|
|
10
12
|
import { useEffect, useState } from 'react';
|
|
11
|
-
import { read } from './api/changes.js';
|
|
13
|
+
import { merge, read } from './api/changes.js';
|
|
12
14
|
import { useRead } from './data.js';
|
|
13
15
|
import { useHost, useTarget } from './host.js';
|
|
14
16
|
import { Out } from './out.js';
|
|
@@ -39,7 +41,39 @@ export function Git({ session, title, live }) {
|
|
|
39
41
|
: 'Open'
|
|
40
42
|
: c.pull.state === 'merged'
|
|
41
43
|
? 'Merged'
|
|
42
|
-
: 'Closed' }), c.pull.url.startsWith('https://') ? (_jsx(Out, { href: c.pull.url, label: `Open pull request #${c.pull.number}`, children: _jsx(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: "Open on the forge" }) })) : null] }), c.pull.reviews.length ? (c.pull.reviews.map((r, i) => (_jsxs(YStack, { gap: "$1", pb: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: [r.author || 'Someone', verdict(r.state), when(r.at)].filter(Boolean).join(' · ') }), r.body ? (_jsx(SizableText, { size: "$2", color: "$ink", children: r.body })) : null] }, i)))) : (_jsx(Soft, { children: "No reviews yet" }))] })) : (_jsx(Soft, { children: "No pull request yet. The run opens one when it pushes its changes." }))) : c.commits.length ? (_jsx(YStack, { children: c.commits.map((k) => (_jsxs(XStack, { gap: "$3", py: "$2", borderBottomWidth: 1, borderColor: "$borderColor", items: "baseline", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: k.sha.slice(0, 7) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 2, children: k.message }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [k.author, when(k.date)].filter(Boolean).join(' · ') })] })] }, k.sha))) })) : (_jsx(Soft, { children: "No pushed commits" }))] }));
|
|
44
|
+
: 'Closed' }), c.pull.url.startsWith('https://') ? (_jsx(Out, { href: c.pull.url, label: `Open pull request #${c.pull.number}`, children: _jsx(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: "Open on the forge" }) })) : null, _jsx(Merge, { session: session, open: c.pull.state === 'open', onMerged: reload })] }), c.pull.reviews.length ? (c.pull.reviews.map((r, i) => (_jsxs(YStack, { gap: "$1", pb: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: [r.author || 'Someone', verdict(r.state), when(r.at)].filter(Boolean).join(' · ') }), r.body ? (_jsx(SizableText, { size: "$2", color: "$ink", children: r.body })) : null] }, i)))) : (_jsx(Soft, { children: "No reviews yet" }))] })) : (_jsx(Soft, { children: "No pull request yet. The run opens one when it pushes its changes." }))) : c.commits.length ? (_jsx(YStack, { children: c.commits.map((k) => (_jsxs(XStack, { gap: "$3", py: "$2", borderBottomWidth: 1, borderColor: "$borderColor", items: "baseline", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: k.sha.slice(0, 7) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 2, children: k.message }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [k.author, when(k.date)].filter(Boolean).join(' · ') })] })] }, k.sha))) })) : (_jsx(Soft, { children: "No pushed commits" }))] }));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Merge for a run's pull request, shown while it is open: `POST …/merge` lands
|
|
48
|
+
* the run's branch in its base at the commit the forge holds now. One press is
|
|
49
|
+
* one merge — pressed again while it is out, nothing more is sent — and a
|
|
50
|
+
* refusal says the forge's reason. `onMerged` reads the run again, which is
|
|
51
|
+
* what closes the button: the pull request reads merged.
|
|
52
|
+
*/
|
|
53
|
+
export function Merge({ session, open, onMerged }) {
|
|
54
|
+
const t = useTarget();
|
|
55
|
+
const [busy, setBusy] = useState(false);
|
|
56
|
+
const [note, setNote] = useState('');
|
|
57
|
+
const press = async () => {
|
|
58
|
+
if (busy)
|
|
59
|
+
return;
|
|
60
|
+
setBusy(true);
|
|
61
|
+
setNote('');
|
|
62
|
+
try {
|
|
63
|
+
const done = await merge(t, session);
|
|
64
|
+
setNote(done.base ? `Merged into ${done.base}` : 'Merged');
|
|
65
|
+
onMerged();
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
setNote(e.message);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
setBusy(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
if (!open && !note)
|
|
75
|
+
return null;
|
|
76
|
+
return (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [open ? (_jsx(Button, { size: "sm", variant: "outline", "aria-busy": busy, onPress: () => void press(), children: busy ? 'Merging…' : 'Merge' })) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null] }));
|
|
43
77
|
}
|
|
44
78
|
function File({ change: f, first }) {
|
|
45
79
|
const [open, setOpen] = useState(false);
|
package/lib/host.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface Host {
|
|
|
25
25
|
token: () => string | null;
|
|
26
26
|
/** The org every call is scoped to. */
|
|
27
27
|
org: string | null;
|
|
28
|
+
/** The org's own logo, an https address, when it has chosen one; its mark is drawn from it. */
|
|
29
|
+
logo?: string;
|
|
28
30
|
/** The organizations this person belongs to. The switcher lists these and no others. */
|
|
29
31
|
memberships?: readonly string[];
|
|
30
32
|
/** Stay on this page and scope every read to `org`. */
|
package/lib/project.js
CHANGED
|
@@ -16,6 +16,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
16
16
|
* preview is the project's own deployed address; a run's sandbox serves no
|
|
17
17
|
* address the platform publishes, and nothing here invents one.
|
|
18
18
|
*
|
|
19
|
+
* A project in a workspace the platform made is published by the platform: a
|
|
20
|
+
* template taken there reads `building` until its copy is `live` or failed, and
|
|
21
|
+
* is read again until then; a run that publishes what it pushed says so on its
|
|
22
|
+
* final status, which reads the project again and loads the preview again. Its
|
|
23
|
+
* pull request is merged from beside its link.
|
|
24
|
+
*
|
|
19
25
|
* Derived from the Hanzo App v2 editor (github.com/hanzoai/build-v2), itself
|
|
20
26
|
* derived from OSW Studio and DeepSite (MIT). See NOTICE.
|
|
21
27
|
*/
|
|
@@ -26,19 +32,23 @@ import { Composer, Message } from '@hanzo/ui/chat';
|
|
|
26
32
|
import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
|
|
27
33
|
import { HanzoMark } from '@hanzo/ui/product';
|
|
28
34
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
35
|
+
import { read as pushed } from './api/changes.js';
|
|
29
36
|
import { start } from './api/coding.js';
|
|
30
37
|
import { blob, tree } from './api/git.js';
|
|
31
|
-
import { name as repoName, ours, templates } from './api/projects.js';
|
|
38
|
+
import { deployments, name as repoName, ours, templates } from './api/projects.js';
|
|
32
39
|
import { list, message, stop } from './api/sessions.js';
|
|
33
40
|
import { outcome, pull, said, who } from './api/turn.js';
|
|
34
41
|
import { verdict as record } from './api/verdict.js';
|
|
35
42
|
import { useKept, useProjects, useRead, useRun } from './data.js';
|
|
43
|
+
import { Merge } from './git.js';
|
|
36
44
|
import { useHost, useTarget } from './host.js';
|
|
37
45
|
import { MODES } from './landing.js';
|
|
38
46
|
import { Out } from './out.js';
|
|
39
47
|
import { Publish } from './publish.js';
|
|
40
48
|
import { Attach, compose, Dictate, Files } from './tools.js';
|
|
41
49
|
const LIVE = new Set(['running', 'paused']);
|
|
50
|
+
/** How often a project being published is read again, ms. */
|
|
51
|
+
const AGAIN = 5000;
|
|
42
52
|
/** One run in the conversation: what was asked, and what it came to. */
|
|
43
53
|
function Turn({ run, open, onOpen, project }) {
|
|
44
54
|
const t = useTarget();
|
|
@@ -58,6 +68,19 @@ export function Project({ slug }) {
|
|
|
58
68
|
const signed = Boolean(host.person);
|
|
59
69
|
const all = useProjects(t, signed);
|
|
60
70
|
const project = all.value.find((p) => p.slug === slug) ?? null;
|
|
71
|
+
// A project that has never served reads `building` while it is published, and
|
|
72
|
+
// is read again until it is live or its build failed.
|
|
73
|
+
const building = project?.status === 'building';
|
|
74
|
+
const failed = project?.status === 'error';
|
|
75
|
+
const { reload: again } = all;
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!building)
|
|
78
|
+
return;
|
|
79
|
+
const every = setInterval(again, AGAIN);
|
|
80
|
+
return () => clearInterval(every);
|
|
81
|
+
}, [building, again]);
|
|
82
|
+
// Why the build failed is its latest deployment's to say.
|
|
83
|
+
const failure = useRead(failed ? () => deployments(t, slug) : null, [], [t, slug, failed, project?.updated]);
|
|
61
84
|
const listed = useRead(signed ? () => list(t, { kind: 'coding', project: slug, limit: 50 }) : null, [], [t, signed, slug]);
|
|
62
85
|
// Only the runs on this project's own repository: a record can be moved into
|
|
63
86
|
// any project, and a run's branch is what Files, Code and Publish read.
|
|
@@ -91,6 +114,36 @@ export function Project({ slug }) {
|
|
|
91
114
|
const [publish, setPublish] = useState(null);
|
|
92
115
|
const [switcher, setSwitcher] = useState(false);
|
|
93
116
|
const frame = useRef(null);
|
|
117
|
+
// What was published may be new files at the same address: the project is read
|
|
118
|
+
// for its address, and the frame loads again.
|
|
119
|
+
const fresh = () => {
|
|
120
|
+
again();
|
|
121
|
+
frame.current?.reload();
|
|
122
|
+
};
|
|
123
|
+
// The open run's final status says when it published what it pushed. That is
|
|
124
|
+
// narration, which any member can write, so it is the cue to read the project
|
|
125
|
+
// and never the address framed; one already there when the run was first read
|
|
126
|
+
// is not news.
|
|
127
|
+
const heard = useRef({ run: null, at: 0 });
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!run.record)
|
|
130
|
+
return;
|
|
131
|
+
if (heard.current.run !== current) {
|
|
132
|
+
heard.current = { run: current, at: end.published };
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (end.published <= heard.current.at)
|
|
136
|
+
return;
|
|
137
|
+
heard.current.at = end.published;
|
|
138
|
+
fresh();
|
|
139
|
+
// `fresh` reads what it needs when it runs.
|
|
140
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
+
}, [current, run.record, end.published]);
|
|
142
|
+
// Where the open run's pull request stands on the forge: Merge is offered while it is open.
|
|
143
|
+
const proposal = useRead(signed && current && pr.href ? () => pushed(t, current).then((c) => ({ run: current, pull: c.pull })) : null, null, [t, signed, current, pr.href]);
|
|
144
|
+
const proposing = proposal.value?.run === current && proposal.value.pull?.state === 'open';
|
|
145
|
+
// Files is drawn again after a merge, from what the forge holds now.
|
|
146
|
+
const [round, setRound] = useState(0);
|
|
94
147
|
// Files and Code: native git, at the branch the open run pushed (or the project's own).
|
|
95
148
|
const repo = project?.repo ? repoName(project.repo) : '';
|
|
96
149
|
const ref = run.record?.branch || project?.branch || 'main';
|
|
@@ -124,13 +177,19 @@ export function Project({ slug }) {
|
|
|
124
177
|
.filter((e) => !e.dir && /\.html?$/i.test(e.name) && !/^index\.html?$/i.test(e.name))
|
|
125
178
|
.map((e) => ({ id: `/${e.path}`, label: e.name.replace(/\.html?$/i, '') })),
|
|
126
179
|
], [root.value]);
|
|
127
|
-
// A project taken from a starter and not
|
|
128
|
-
// live page, which is what the copy is until its first publish.
|
|
129
|
-
const forked = project && !project.live ? project.forked : '';
|
|
180
|
+
// A project taken from a starter, not published and not being, shows the
|
|
181
|
+
// starter's own live page, which is what the copy is until its first publish.
|
|
182
|
+
const forked = project && !project.live && !building && !failed ? project.forked : '';
|
|
130
183
|
const starters = useRead(forked ? () => templates(t) : null, [], [t.api, forked]);
|
|
131
184
|
const starter = forked ? starters.value.find((s) => s.slug === forked && s.demo) : undefined;
|
|
132
185
|
const shown = project?.live || starter?.demo || '';
|
|
133
186
|
const src = shown ? new URL(page, shown).toString() : null;
|
|
187
|
+
const why = failure.value[0]?.message || failure.error?.message || (failure.loading ? '' : 'Its build ended without saying why.');
|
|
188
|
+
const merged = () => {
|
|
189
|
+
proposal.reload();
|
|
190
|
+
root.reload();
|
|
191
|
+
setRound((n) => n + 1);
|
|
192
|
+
};
|
|
134
193
|
const lines = useMemo(() => [
|
|
135
194
|
...run.events
|
|
136
195
|
.filter((e) => e.kind === 'log' || e.kind === 'tool-call' || e.kind === 'status')
|
|
@@ -229,18 +288,20 @@ export function Project({ slug }) {
|
|
|
229
288
|
? 'Reading this project’s runs…'
|
|
230
289
|
: project?.live
|
|
231
290
|
? `${project.name} is loaded — it is in the preview, and every run on it is under the clock above. Say what to change and it gets built.`
|
|
232
|
-
:
|
|
233
|
-
? `${project?.name} is
|
|
234
|
-
:
|
|
235
|
-
? `${project
|
|
236
|
-
:
|
|
291
|
+
: building
|
|
292
|
+
? `${project?.name} is being published — it appears in the preview when its build finishes. Say what to change and it gets built.`
|
|
293
|
+
: starter
|
|
294
|
+
? `${project?.name} is loaded — the preview shows the ${starter.title} starter until your copy is published. Say what to change and it gets built.`
|
|
295
|
+
: project
|
|
296
|
+
? `${project.name} is loaded and nothing is published yet. Say what to change and it gets built.`
|
|
297
|
+
: `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { px: "$3", pb: "$3", gap: "$2", children: [!dismissed && !running ? _jsx(Suggestions, { items: SUGGESTIONS, onPick: (s) => void send(s), onDismiss: () => setDismissed(true) }) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsxs(Composer, { value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
|
|
237
298
|
? () => void stop(t, current).then(() => setNote('Stop requested — the run keeps its work on its branch'), (e) => setNote(e.message))
|
|
238
299
|
: undefined, busy: busy || (running && !draft.trim()), rows: 3, placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", children: [_jsx(Attach, { files: attached, onFiles: setAttached, onNote: setNote }), _jsx(XStack, { flex: 1 }), _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), self: "center" }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote })] })] })] }));
|
|
239
|
-
const body = view === 'preview' ? (_jsx(PreviewFrame, { ref: frame, src: src, title: `${project?.name ?? slug} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children:
|
|
300
|
+
const body = view === 'preview' ? (_jsx(PreviewFrame, { ref: frame, src: src, title: `${project?.name ?? slug} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: building ? (project?.forked ? 'Publishing your copy…' : 'Publishing…') : failed ? 'Publishing failed' : 'Nothing deployed yet' }), _jsx(SizableText, { size: "$2", color: "$soft", text: "center", children: building ? 'Its page appears here when the build finishes.' : failed ? why : 'Publish this project and its page appears here.' })] }), toolbar: bridge ? (_jsx(XStack, { render: "button", "aria-pressed": editing, "aria-label": editing ? 'Stop picking elements' : 'Pick an element to edit', onPress: () => {
|
|
240
301
|
const next = !editing;
|
|
241
302
|
setEditing(next);
|
|
242
303
|
frame.current?.post({ type: 'preview:editable', active: next });
|
|
243
|
-
}, p: "$2", rounded: "$10", children: _jsx(MousePointerClick, { size: 16 }) })) : undefined })) : view === 'files' ? (repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` })) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
|
|
304
|
+
}, p: "$2", rounded: "$10", children: _jsx(MousePointerClick, { size: 16 }) })) : undefined })) : view === 'files' ? (repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` }, round)) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
|
|
244
305
|
setFiles((was) => was.filter((f) => f.path !== p));
|
|
245
306
|
if (file === p)
|
|
246
307
|
setFile(null);
|
|
@@ -259,5 +320,5 @@ export function Project({ slug }) {
|
|
|
259
320
|
}, justify: "space-between", px: "$2", py: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, children: r.title || 'Untitled run' }), _jsx(SizableText, { size: "$1", color: "$soft", children: r.status })] }, r.id)))) })] }) }), _jsx(Dialog, { open: switcher, onOpenChange: setSwitcher, children: _jsxs(DialogContent, { maxW: 420, children: [_jsx(DialogTitle, { children: "Projects" }), _jsx(YStack, { gap: "$1", maxH: 420, overflow: "scroll", children: all.value.map((p) => (_jsx(XStack, { render: "button", onPress: () => {
|
|
260
321
|
setSwitcher(false);
|
|
261
322
|
host.go(p.slug);
|
|
262
|
-
}, px: "$2", py: "$1.5", rounded: "$3", bg: p.slug === slug ? '$hover' : undefined, hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: p.name }) }, p.slug))) })] }) }), _jsx(Publish, { source: publish, onClose: () => setPublish(null) })] }));
|
|
323
|
+
}, px: "$2", py: "$1.5", rounded: "$3", bg: p.slug === slug ? '$hover' : undefined, hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: p.name }) }, p.slug))) })] }) }), _jsx(Publish, { source: publish, onClose: () => setPublish(null), onLive: fresh })] }));
|
|
263
324
|
}
|
package/lib/publish.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export interface Source {
|
|
|
9
9
|
/** The project to add it to, when there already is one. */
|
|
10
10
|
project?: string;
|
|
11
11
|
}
|
|
12
|
-
export declare function Publish({ source, onClose }: {
|
|
12
|
+
export declare function Publish({ source, onClose, onLive }: {
|
|
13
13
|
source: Source | null;
|
|
14
14
|
onClose: () => void;
|
|
15
|
+
onLive?: () => void;
|
|
15
16
|
}): import("react").JSX.Element | null;
|
package/lib/publish.js
CHANGED
|
@@ -9,17 +9,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
* build to main, which CD applies: that is the second call, with the build's
|
|
10
10
|
* tag, because main refuses an image that is not built yet.
|
|
11
11
|
*
|
|
12
|
+
* A repository in the org's own code workspace answers `live` instead: its
|
|
13
|
+
* build puts it on the project's address when it finishes, so there is no
|
|
14
|
+
* review and nothing to take to main. Its build is followed on the board like
|
|
15
|
+
* any other, and `onLive` is told when it succeeds, so the host reads the
|
|
16
|
+
* project's address again.
|
|
17
|
+
*
|
|
12
18
|
* Every state shown is one the platform reported; a refusal shows the
|
|
13
19
|
* platform's own sentence.
|
|
14
20
|
*/
|
|
15
21
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
16
22
|
import { ExternalLink } from '@hanzogui/lucide-icons-2';
|
|
17
23
|
import { Button, Dialog, DialogContent, DialogDescription, DialogTitle, Input } from '@hanzo/ui';
|
|
18
|
-
import { useEffect, useState } from 'react';
|
|
24
|
+
import { useEffect, useRef, useState } from 'react';
|
|
19
25
|
import { builds, declare, label } from './api/platform.js';
|
|
20
26
|
import { useHost, useTarget } from './host.js';
|
|
21
27
|
import { Out } from './out.js';
|
|
22
|
-
export function Publish({ source, onClose }) {
|
|
28
|
+
export function Publish({ source, onClose, onLive }) {
|
|
23
29
|
const t = useTarget();
|
|
24
30
|
const host = useHost();
|
|
25
31
|
const [project, setProject] = useState('');
|
|
@@ -27,6 +33,8 @@ export function Publish({ source, onClose }) {
|
|
|
27
33
|
const [out, setOut] = useState(null);
|
|
28
34
|
const [status, setStatus] = useState('');
|
|
29
35
|
const [error, setError] = useState('');
|
|
36
|
+
const told = useRef(onLive);
|
|
37
|
+
told.current = onLive;
|
|
30
38
|
useEffect(() => {
|
|
31
39
|
setProject(source?.project ?? source?.name ?? '');
|
|
32
40
|
setPhase('ask');
|
|
@@ -57,6 +65,12 @@ export function Publish({ source, onClose }) {
|
|
|
57
65
|
clearInterval(every);
|
|
58
66
|
};
|
|
59
67
|
}, [t, out, phase]);
|
|
68
|
+
const green = /^(succeeded|success|done|complete|completed|ok)$/i.test(status);
|
|
69
|
+
// A site's build going green is the site going live, at the project's address.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (out?.live && green)
|
|
72
|
+
told.current?.();
|
|
73
|
+
}, [out, green]);
|
|
60
74
|
if (!source)
|
|
61
75
|
return null;
|
|
62
76
|
const send = async () => {
|
|
@@ -75,7 +89,6 @@ export function Publish({ source, onClose }) {
|
|
|
75
89
|
};
|
|
76
90
|
// The build this write started; its status is what the platform last said of it.
|
|
77
91
|
const built = out?.build?.id ?? '';
|
|
78
|
-
const green = /^(succeeded|success|done|complete|completed|ok)$/i.test(status);
|
|
79
92
|
// Offered only once that build is green.
|
|
80
93
|
const ship = async () => {
|
|
81
94
|
setPhase('shipping');
|
|
@@ -89,7 +102,11 @@ export function Publish({ source, onClose }) {
|
|
|
89
102
|
setPhase('declared');
|
|
90
103
|
}
|
|
91
104
|
};
|
|
92
|
-
return (_jsx(Dialog, { open: true, onOpenChange: (o) => !o && onClose(), children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Add to project" }),
|
|
105
|
+
return (_jsx(Dialog, { open: true, onOpenChange: (o) => !o && onClose(), children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Add to project" }), _jsx(DialogDescription, { children: out?.live
|
|
106
|
+
? `Build ${source.title} at ${source.ref} and publish it at its project’s address.`
|
|
107
|
+
: `Build ${source.title} at ${source.ref} and declare it in a project. ${host.admin ? 'As an admin you can take a green build to main.' : 'It opens a review; merging it is what deploys.'}` }), _jsxs(YStack, { gap: "$1.5", children: [_jsx(SizableText, { render: "label", size: "$2", color: "$soft", htmlFor: "publish-project", children: "Project" }), _jsx(Input, { id: "publish-project", value: project, onChangeText: (v) => setProject(v), disabled: phase !== 'ask' && phase !== 'failed', placeholder: label(source.name), "aria-label": "Project" }), _jsxs(SizableText, { size: "$1", color: "$soft", children: ["Saved as ", label(project || source.name)] })] }), out ? (_jsxs(YStack, { gap: "$2", role: "status", children: [out.notice ? (_jsx(SizableText, { size: "$2", color: "$soft", children: out.notice })) : null, _jsx(SizableText, { size: "$2", color: "$ink", children: phase === 'shipped'
|
|
93
108
|
? 'Declared on main — CD applies it on its next pass.'
|
|
94
|
-
:
|
|
109
|
+
: out.live && green
|
|
110
|
+
? `Build ${built} succeeded — it is live at the project’s address.`
|
|
111
|
+
: `Build ${built}: ${status || 'building'}` }), out.review ? (_jsxs(Out, { href: out.review, children: [_jsx(SizableText, { size: "$2", color: "$ink", textDecorationLine: "underline", children: "Open the review" }), _jsx(ExternalLink, { size: 12, opacity: 0.6 })] })) : null] })) : null, error ? (_jsx(SizableText, { size: "$2", color: "$red10", role: "alert", children: error })) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { variant: "outline", size: "sm", onPress: onClose, children: phase === 'shipped' || phase === 'declared' ? 'Done' : 'Cancel' }), phase === 'ask' || phase === 'failed' || phase === 'sending' ? (_jsx(Button, { size: "sm", disabled: phase === 'sending', onPress: () => void send(), children: phase === 'sending' ? 'Adding…' : 'Add to project' })) : host.admin && !out?.live && (phase === 'declared' || phase === 'shipping') ? (_jsx(Button, { size: "sm", disabled: !green || phase === 'shipping', onPress: () => void ship(), children: phase === 'shipping' ? 'Shipping…' : green ? 'Ship to main' : 'Waiting for the build' })) : null] })] }) }));
|
|
95
112
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/build",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.26",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",
|