my-durable 0.0.21 → 0.0.23
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/apps/dpi-backend/src/git.ts +66 -11
- package/apps/dpi-backend/src/routes.ts +6 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-BT2MDDdt.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CclVXMo8.js +78 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/git-store.ts +101 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/session-git.ts +28 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/GitChip.vue +22 -159
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/GitPanel.vue +225 -134
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +6 -0
- package/apps/dpi-sdk/src/client.ts +15 -4
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +1 -0
- package/apps/dpi-tui/src/app.ts +25 -2
- package/apps/dpi-tui/src/views.ts +7 -3
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-BvDyZN2S.css +0 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cv3w8azJ.js +0 -80
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<meta name="referrer" content="no-referrer" />
|
|
8
8
|
<link rel="icon" type="image/svg+xml" href="/icons/durable-pi.svg" />
|
|
9
9
|
<title>Durable Pi | Network</title>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-CclVXMo8.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BT2MDDdt.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="app"></div>
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
toggleTree,
|
|
34
34
|
toggleInspector,
|
|
35
35
|
} from "./client/store";
|
|
36
|
+
import { watchSessionGit } from "./client/session-git";
|
|
36
37
|
import { auth } from "./client/auth";
|
|
37
38
|
|
|
38
39
|
const onlineCount = () => network.devices.value.filter((d) => d.status === "online").length;
|
|
@@ -76,6 +77,7 @@ function resize(e: PointerEvent) {
|
|
|
76
77
|
window.addEventListener("pointerup", up);
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
onMounted(() => watchSessionGit());
|
|
79
81
|
onMounted(() => {
|
|
80
82
|
document.addEventListener("keydown", keydown);
|
|
81
83
|
void boot();
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Git state, shared and LAZY: one entry per device+folder, fetched only when
|
|
2
|
+
// something actually looks at it, kept across tab switches, never re-fetched
|
|
3
|
+
// while fresh. In-flight calls are shared so chip + panel + re-entry of either
|
|
4
|
+
// cost ONE device round-trip.
|
|
5
|
+
import { reactive } from "vue";
|
|
6
|
+
import type { GitStatus } from "@sdk/client";
|
|
7
|
+
import { deviceClient } from "./hub";
|
|
8
|
+
|
|
9
|
+
export interface GitEntry {
|
|
10
|
+
at: number; // last settled fetch (0 = never)
|
|
11
|
+
status: GitStatus | null;
|
|
12
|
+
error: string;
|
|
13
|
+
loading: boolean; // first fetch, nothing to show yet
|
|
14
|
+
refreshing: boolean; // refetch with data on screen
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const entries = new Map<string, GitEntry>();
|
|
18
|
+
const inflight = new Map<string, Promise<void>>();
|
|
19
|
+
|
|
20
|
+
/** Fill MANY folders in one round-trip (session lists want branch-per-cwd).
|
|
21
|
+
* Decorative by design: a failed batch stays silent — the Git tab is where
|
|
22
|
+
* errors belong. */
|
|
23
|
+
export async function ensureGitBatch(deviceId: string, paths: string[]) {
|
|
24
|
+
const stale = paths.filter((p) => !fresh(gitEntry(deviceId, p), staleMsForBatch));
|
|
25
|
+
if (!stale.length) return;
|
|
26
|
+
try {
|
|
27
|
+
const map = await deviceClient(deviceId).gitStatusBatch(stale, { timeoutMs: 60_000 });
|
|
28
|
+
for (const [p, s] of Object.entries(map)) {
|
|
29
|
+
const e = gitEntry(deviceId, p);
|
|
30
|
+
e.status = s;
|
|
31
|
+
e.at = Date.now();
|
|
32
|
+
e.error = "";
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
/* decorative: never surface batch failures */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const staleMsForBatch = 60_000;
|
|
39
|
+
|
|
40
|
+
/** Test hook: the store is module-global on purpose (tab switches must not
|
|
41
|
+
* refetch) — tests reset it so entries never leak between cases. */
|
|
42
|
+
export function clearGitCache() {
|
|
43
|
+
entries.clear();
|
|
44
|
+
inflight.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const key = (deviceId: string, cwd: string) => `${deviceId}::${cwd}`;
|
|
48
|
+
|
|
49
|
+
export function gitEntry(deviceId: string, cwd: string): GitEntry {
|
|
50
|
+
const k = key(deviceId, cwd);
|
|
51
|
+
let e = entries.get(k);
|
|
52
|
+
if (!e) {
|
|
53
|
+
e = reactive({ at: 0, status: null, error: "", loading: false, refreshing: false });
|
|
54
|
+
entries.set(k, e);
|
|
55
|
+
}
|
|
56
|
+
return e;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when the entry is fresh enough to show without touching the device. */
|
|
60
|
+
const fresh = (e: GitEntry, staleMs: number) => e.at > 0 && Date.now() - e.at < staleMs && (e.status !== null || e.error);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fetch only when nothing usable is cached. `force` bypasses the TTL (the
|
|
64
|
+
* retry/refresh buttons and post-op refreshes use it).
|
|
65
|
+
*/
|
|
66
|
+
export async function ensureGit(
|
|
67
|
+
deviceId: string,
|
|
68
|
+
cwd: string,
|
|
69
|
+
opts: { staleMs?: number; force?: boolean; timeoutMs?: number } = {},
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
const { staleMs = 60_000, force = false, timeoutMs = 60_000 } = opts;
|
|
72
|
+
if (!deviceId || !cwd) return;
|
|
73
|
+
const k = key(deviceId, cwd);
|
|
74
|
+
const e = gitEntry(deviceId, cwd);
|
|
75
|
+
if (!force && fresh(e, staleMs)) return;
|
|
76
|
+
const running = inflight.get(k);
|
|
77
|
+
if (running) return running;
|
|
78
|
+
const first = e.status === null && !e.error;
|
|
79
|
+
if (first) e.loading = true;
|
|
80
|
+
else e.refreshing = true;
|
|
81
|
+
e.error = ""; // a new attempt must not stack on the old verdict
|
|
82
|
+
const p = (async () => {
|
|
83
|
+
try {
|
|
84
|
+
// Big checkouts honestly take 20s+ — wait for the truth.
|
|
85
|
+
e.status = await deviceClient(deviceId).gitStatus(cwd, { timeoutMs });
|
|
86
|
+
e.at = Date.now();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const raw = (err as Error).message ?? "";
|
|
89
|
+
e.error = /fetch failed|Cannot reach|Failed to fetch|NetworkError|abort/i.test(raw)
|
|
90
|
+
? `device unreachable — check that it is online, then retry (${raw.slice(0, 80)})`
|
|
91
|
+
: raw.slice(0, 160);
|
|
92
|
+
e.at = Date.now(); // a failed attempt is still an attempt: no hammering
|
|
93
|
+
} finally {
|
|
94
|
+
e.loading = false;
|
|
95
|
+
e.refreshing = false;
|
|
96
|
+
inflight.delete(k);
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
inflight.set(k, p);
|
|
100
|
+
return p;
|
|
101
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Branch-per-session in the tree: watches the network feed and batch-fetches
|
|
2
|
+
// git status for every ONLINE device's distinct session folders — at most one
|
|
3
|
+
// call per device per minute (the shared store dedupes everything else).
|
|
4
|
+
import { watch } from "vue";
|
|
5
|
+
import { network } from "./store";
|
|
6
|
+
import { ensureGitBatch, gitEntry } from "./git-store";
|
|
7
|
+
|
|
8
|
+
const lastBatch = new Map<string, number>();
|
|
9
|
+
|
|
10
|
+
/** Install once from App setup (client only). */
|
|
11
|
+
export function watchSessionGit() {
|
|
12
|
+
watch(
|
|
13
|
+
() => network.devices.value.map((d) => `${d.id}:${d.status}:${d.tree?.sessions.length ?? 0}:${d.tree?.sessions.map((s) => s.cwd).join("|")}`).join(";"),
|
|
14
|
+
() => {
|
|
15
|
+
for (const d of network.devices.value) {
|
|
16
|
+
if (d.status !== "online" || !d.tree?.sessions.length) continue;
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
if (now - (lastBatch.get(d.id) ?? 0) < 60_000) continue;
|
|
19
|
+
lastBatch.set(d.id, now);
|
|
20
|
+
void ensureGitBatch(d.id, d.tree.sessions.map((s) => s.cwd));
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{ immediate: true },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** ⎇ branch for a session row, when we already know it (never triggers I/O). */
|
|
28
|
+
export const branchOf = (deviceId: string, cwd: string) => gitEntry(deviceId, cwd).status?.branch;
|
|
@@ -1,161 +1,40 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Git chip for the session breadcrumb: branch + dirty at a glance
|
|
3
|
-
// the
|
|
4
|
-
//
|
|
5
|
-
import { computed,
|
|
6
|
-
import { ChevronDown, GitBranch
|
|
7
|
-
import { deviceId, sessionSummary,
|
|
8
|
-
import {
|
|
9
|
-
import type { GitStatus } from "@sdk/client";
|
|
2
|
+
// Git chip for the session breadcrumb: branch + dirty at a glance. Rides the
|
|
3
|
+
// same LAZY shared store as the Git tab — one fetch per folder while fresh,
|
|
4
|
+
// no polling, survives every tab/overlay switch. Click opens the Git tab.
|
|
5
|
+
import { computed, onServerPrefetch, watch } from "vue";
|
|
6
|
+
import { ChevronDown, GitBranch } from "lucide-vue-next";
|
|
7
|
+
import { deviceId, sessionSummary, showInspector } from "../client/store";
|
|
8
|
+
import { ensureGit, gitEntry } from "../client/git-store";
|
|
10
9
|
|
|
11
10
|
const props = defineProps<{ deviceId?: string; cwd?: string }>();
|
|
12
|
-
const status = ref<GitStatus | null>(null);
|
|
13
|
-
const open = ref(false);
|
|
14
|
-
const busy = ref("");
|
|
15
|
-
const logText = ref("");
|
|
16
|
-
const commitMsg = ref("");
|
|
17
|
-
const wtName = ref("");
|
|
18
11
|
|
|
19
12
|
const cwd = computed(() => props.cwd ?? sessionSummary.value?.cwd ?? "");
|
|
20
13
|
const devId = computed(() => props.deviceId ?? deviceId.value);
|
|
14
|
+
const entry = computed(() => gitEntry(devId.value || "?", cwd.value || "?"));
|
|
15
|
+
const status = computed(() => entry.value.status);
|
|
16
|
+
const loading = computed(() => entry.value.loading);
|
|
21
17
|
const dirty = computed(() => (status.value?.staged ?? 0) + (status.value?.unstaged ?? 0) + (status.value?.untracked ?? 0));
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
status.value = await deviceClient(devId.value).gitStatus(cwd.value);
|
|
28
|
-
} catch (e) {
|
|
29
|
-
status.value = null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
// Client: fire now, the chip fills reactively. SSR waits on the prefetch
|
|
33
|
-
// hook — a top-level await would need <Suspense> and silently never mount.
|
|
34
|
-
if (!import.meta.env.SSR) void refresh();
|
|
35
|
-
onServerPrefetch(() => refresh());
|
|
36
|
-
watch(cwd, () => void refresh());
|
|
37
|
-
onMounted(() => {
|
|
38
|
-
timer = setInterval(refresh, 15000);
|
|
39
|
-
});
|
|
40
|
-
onBeforeUnmount(() => timer && clearInterval(timer));
|
|
41
|
-
|
|
42
|
-
async function op(name: "fetch" | "pull" | "log" | "branch", keepOpen = true) {
|
|
43
|
-
if (!devId.value || !cwd.value) return;
|
|
44
|
-
busy.value = name;
|
|
45
|
-
logText.value = "";
|
|
46
|
-
try {
|
|
47
|
-
const out = await deviceClient(devId.value).gitOp(cwd.value, name);
|
|
48
|
-
if (name === "log" || name === "branch") logText.value = out.text;
|
|
49
|
-
else notify(`git ${name}: ${out.text.split("\n")[0] ?? "ok"}`);
|
|
50
|
-
await refresh();
|
|
51
|
-
} catch (e) {
|
|
52
|
-
notify(`git ${name} failed: ${(e as Error).message.slice(0, 120)}`);
|
|
53
|
-
} finally {
|
|
54
|
-
busy.value = "";
|
|
55
|
-
void keepOpen;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function commit() {
|
|
60
|
-
if (!devId.value || !cwd.value || !commitMsg.value.trim()) return;
|
|
61
|
-
busy.value = "commit";
|
|
62
|
-
try {
|
|
63
|
-
const out = await deviceClient(devId.value).gitOp(cwd.value, "commit", { message: commitMsg.value.trim() });
|
|
64
|
-
notify(out.text);
|
|
65
|
-
commitMsg.value = "";
|
|
66
|
-
await refresh();
|
|
67
|
-
} catch (e) {
|
|
68
|
-
notify(`commit failed: ${(e as Error).message.slice(0, 120)}`);
|
|
69
|
-
} finally {
|
|
70
|
-
busy.value = "";
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function addWorktree() {
|
|
75
|
-
if (!devId.value || !cwd.value || !wtName.value.trim() || !status.value?.root) return;
|
|
76
|
-
busy.value = "wt";
|
|
77
|
-
const root = status.value.root;
|
|
78
|
-
const parent = root.slice(0, root.lastIndexOf("/"));
|
|
79
|
-
const base = root.slice(root.lastIndexOf("/") + 1);
|
|
80
|
-
const target = `${parent}/${base}-${wtName.value.trim()}`;
|
|
81
|
-
try {
|
|
82
|
-
const out = await deviceClient(devId.value).gitOp(cwd.value, "worktree-add", {
|
|
83
|
-
branch: wtName.value.trim(),
|
|
84
|
-
target,
|
|
85
|
-
});
|
|
86
|
-
notify(`worktree: ${out.text.split("\n")[0] ?? target}`);
|
|
87
|
-
wtName.value = "";
|
|
88
|
-
await refresh();
|
|
89
|
-
} catch (e) {
|
|
90
|
-
notify(`worktree failed: ${(e as Error).message.slice(0, 120)}`);
|
|
91
|
-
} finally {
|
|
92
|
-
busy.value = "";
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function removeWorktree(path: string) {
|
|
97
|
-
if (!devId.value || !cwd.value) return;
|
|
98
|
-
if (!confirm(`Remove worktree ${path}?`)) return;
|
|
99
|
-
busy.value = "wt";
|
|
100
|
-
try {
|
|
101
|
-
await deviceClient(devId.value).gitOp(cwd.value, "worktree-remove", { target: path });
|
|
102
|
-
await refresh();
|
|
103
|
-
} catch (e) {
|
|
104
|
-
notify(`remove failed: ${(e as Error).message.slice(0, 120)}`);
|
|
105
|
-
} finally {
|
|
106
|
-
busy.value = "";
|
|
107
|
-
}
|
|
108
|
-
}
|
|
19
|
+
if (!import.meta.env.SSR) void ensureGit(devId.value || "?", cwd.value || "?");
|
|
20
|
+
onServerPrefetch(() => ensureGit(devId.value || "?", cwd.value || "?"));
|
|
21
|
+
watch([devId, cwd], () => void ensureGit(devId.value || "?", cwd.value || "?"));
|
|
109
22
|
</script>
|
|
110
23
|
|
|
111
24
|
<template>
|
|
112
|
-
<span v-if="status?.repo" class="git-chip-wrap">
|
|
113
|
-
<button
|
|
25
|
+
<span v-if="status?.repo || loading" class="git-chip-wrap">
|
|
26
|
+
<button
|
|
27
|
+
class="crumb git-chip"
|
|
28
|
+
:title="status ? `${status.root} · ${status.head ?? ''} ${status.subject ?? ''} — opens the Git tab` : 'loading git…'"
|
|
29
|
+
@click="showInspector('git')"
|
|
30
|
+
>
|
|
114
31
|
<GitBranch :size="16" aria-hidden="true" />
|
|
115
|
-
<span class="branch">{{ status
|
|
32
|
+
<span class="branch">{{ status?.branch ?? status?.head ?? "…" }}</span>
|
|
116
33
|
<span v-if="dirty" class="dirty">±{{ dirty }}</span>
|
|
117
|
-
<span v-if="status
|
|
118
|
-
<span v-if="status
|
|
34
|
+
<span v-if="status?.ahead" class="ahead">↑{{ status.ahead }}</span>
|
|
35
|
+
<span v-if="status?.behind" class="behind">↓{{ status.behind }}</span>
|
|
119
36
|
<ChevronDown :size="14" aria-hidden="true" />
|
|
120
37
|
</button>
|
|
121
|
-
<div v-if="open" class="git-panel" @click.stop>
|
|
122
|
-
<header>
|
|
123
|
-
<strong>git · {{ status.root }}</strong>
|
|
124
|
-
<button :disabled="!!busy" title="refresh" @click="refresh()"><RefreshCw :size="14" /></button>
|
|
125
|
-
</header>
|
|
126
|
-
<p class="line">
|
|
127
|
-
{{ status.branch ?? "detached" }}<template v-if="status.upstream"> → {{ status.upstream }}</template>
|
|
128
|
-
· <span title="staged/unstaged/untracked">+{{ status.staged ?? 0 }} ~{{ status.unstaged ?? 0 }} ?{{ status.untracked ?? 0 }}</span>
|
|
129
|
-
</p>
|
|
130
|
-
<p class="line muted">{{ status.head }} {{ status.subject }}</p>
|
|
131
|
-
<p v-if="status.worktreeOf" class="line wt-of">worktree of {{ status.worktreeOf }}</p>
|
|
132
|
-
|
|
133
|
-
<div class="row">
|
|
134
|
-
<button :disabled="!!busy" @click="op('fetch')">fetch</button>
|
|
135
|
-
<button :disabled="!!busy" @click="op('pull')">pull</button>
|
|
136
|
-
<button :disabled="!!busy" @click="op('log')">log</button>
|
|
137
|
-
<button :disabled="!!busy" @click="op('branch')">branches</button>
|
|
138
|
-
</div>
|
|
139
|
-
|
|
140
|
-
<div class="row commit">
|
|
141
|
-
<input v-model="commitMsg" placeholder="commit message…" @keyup.enter="commit" />
|
|
142
|
-
<button :disabled="!!busy || !commitMsg.trim()" @click="commit">commit -am</button>
|
|
143
|
-
</div>
|
|
144
|
-
|
|
145
|
-
<div class="row wt">
|
|
146
|
-
<input v-model="wtName" placeholder="branch name…" @keyup.enter="addWorktree" />
|
|
147
|
-
<button :disabled="!!busy || !wtName.trim()" @click="addWorktree">+ worktree</button>
|
|
148
|
-
</div>
|
|
149
|
-
<ul v-if="(status.worktrees?.length ?? 0) > 1" class="wts">
|
|
150
|
-
<li v-for="w in status.worktrees" :key="w.path">
|
|
151
|
-
<code>{{ w.path }}</code>
|
|
152
|
-
<span class="muted">{{ w.branch ?? w.head }}</span>
|
|
153
|
-
<button v-if="w.path !== status.root" :disabled="!!busy" @click="removeWorktree(w.path)">remove</button>
|
|
154
|
-
</li>
|
|
155
|
-
</ul>
|
|
156
|
-
|
|
157
|
-
<pre v-if="logText" class="log">{{ logText.slice(0, 4000) }}</pre>
|
|
158
|
-
</div>
|
|
159
38
|
</span>
|
|
160
39
|
</template>
|
|
161
40
|
|
|
@@ -166,20 +45,4 @@ async function removeWorktree(path: string) {
|
|
|
166
45
|
.dirty { color: var(--warn, #e5a50a); }
|
|
167
46
|
.ahead { color: var(--ok, #2ea043); }
|
|
168
47
|
.behind { color: var(--info, #58a6ff); }
|
|
169
|
-
.git-panel {
|
|
170
|
-
position: absolute; top: calc(100% + 6px); left: 0; z-index: 40; min-width: 320px; max-width: min(560px, 90vw);
|
|
171
|
-
background: var(--bg-2, #1c1c22); border: 1px solid var(--border, #333); border-radius: 8px; padding: 10px 12px;
|
|
172
|
-
box-shadow: 0 8px 24px rgb(0 0 0 / 40%); font-size: 12px;
|
|
173
|
-
}
|
|
174
|
-
.git-panel header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
|
175
|
-
.git-panel .line { margin: 4px 0; }
|
|
176
|
-
.git-panel .muted, .muted { opacity: 0.7; }
|
|
177
|
-
.git-panel .wt-of { color: var(--info, #58a6ff); }
|
|
178
|
-
.git-panel .row { display: flex; gap: 6px; margin: 8px 0; flex-wrap: wrap; }
|
|
179
|
-
.git-panel .row.commit, .git-panel .row.wt { flex-wrap: nowrap; }
|
|
180
|
-
.git-panel input { flex: 1; min-width: 0; }
|
|
181
|
-
.git-panel .wts { list-style: none; padding: 0; margin: 4px 0; display: grid; gap: 2px; }
|
|
182
|
-
.git-panel .wts li { display: flex; gap: 8px; align-items: center; }
|
|
183
|
-
.git-panel .wts code { font-size: 11px; }
|
|
184
|
-
.git-panel .log { max-height: 220px; overflow: auto; background: rgb(0 0 0 / 25%); padding: 8px; border-radius: 6px; }
|
|
185
48
|
</style>
|