Archived
feat: make refresh control reload the page
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Make the app refresh control perform a full page reload directly instead of opening refresh-data options.
|
||||||
@@ -1364,7 +1364,7 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderAppRefresh() {
|
private renderAppRefresh() {
|
||||||
return html`<app-refresh-control .isRefreshing=${this.isRefreshingApp} .onRefresh=${() => this.refreshAppData()} .onReload=${() => { this.hardReloadApp(); }}></app-refresh-control>`;
|
return html`<app-refresh-control .onReload=${() => { this.hardReloadApp(); }}></app-refresh-control>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
|
|||||||
@@ -1,51 +1,18 @@
|
|||||||
import { LitElement, css, html } from "lit";
|
import { LitElement, css, html } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import { actionMenuPanelStyle } from "../actionMenu";
|
|
||||||
|
|
||||||
const REFRESH_LONG_PRESS_MS = 550;
|
|
||||||
const REFRESH_MENU_PORTAL_STYLE_ID = "pi-web-app-refresh-menu-portal-style";
|
|
||||||
|
|
||||||
@customElement("app-refresh-control")
|
@customElement("app-refresh-control")
|
||||||
export class AppRefreshControl extends LitElement {
|
export class AppRefreshControl extends LitElement {
|
||||||
@property({ type: Boolean }) isRefreshing = false;
|
|
||||||
@property({ attribute: false }) onRefresh?: () => void | Promise<void>;
|
|
||||||
@property({ attribute: false }) onReload?: () => void;
|
@property({ attribute: false }) onReload?: () => void;
|
||||||
@state() private menuOpen = false;
|
|
||||||
private menuStyle = "";
|
|
||||||
private menuPortal: HTMLDivElement | undefined;
|
|
||||||
private longPressTimer: number | undefined;
|
|
||||||
private suppressNextClick = false;
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
document.addEventListener("click", this.onDocumentClick);
|
|
||||||
document.addEventListener("keydown", this.onDocumentKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
|
||||||
document.removeEventListener("click", this.onDocumentClick);
|
|
||||||
document.removeEventListener("keydown", this.onDocumentKeyDown);
|
|
||||||
this.clearLongPressTimer();
|
|
||||||
this.removePortalMenu();
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const label = this.isRefreshing ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options.";
|
const label = "Full page reload";
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
class=${`app-refresh-button${this.isRefreshing ? " refreshing" : ""}`}
|
class="app-refresh-button"
|
||||||
title=${label}
|
title=${label}
|
||||||
aria-label=${label}
|
aria-label=${label}
|
||||||
aria-haspopup="menu"
|
@click=${this.onReloadClick}
|
||||||
aria-expanded=${String(this.menuOpen)}
|
|
||||||
aria-busy=${String(this.isRefreshing)}
|
|
||||||
@click=${this.onRefreshClick}
|
|
||||||
@contextmenu=${this.onRefreshContextMenu}
|
|
||||||
@pointerdown=${this.onRefreshPointerDown}
|
|
||||||
@pointerup=${() => { this.clearLongPressTimer(); }}
|
|
||||||
@pointercancel=${() => { this.clearLongPressTimer(); }}
|
|
||||||
@pointerleave=${() => { this.clearLongPressTimer(); }}
|
|
||||||
>${this.renderRefreshIcon()}</button>
|
>${this.renderRefreshIcon()}</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -61,114 +28,9 @@ export class AppRefreshControl extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly onRefreshClick = (event: MouseEvent): void => {
|
private readonly onReloadClick = (event: MouseEvent): void => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (this.suppressNextClick) {
|
|
||||||
this.suppressNextClick = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly onRefreshPointerDown = (event: PointerEvent): void => {
|
|
||||||
if (!event.isPrimary || event.button !== 0) return;
|
|
||||||
const target = event.currentTarget;
|
|
||||||
if (!(target instanceof HTMLElement)) return;
|
|
||||||
this.clearLongPressTimer();
|
|
||||||
this.suppressNextClick = false;
|
|
||||||
this.longPressTimer = window.setTimeout(() => {
|
|
||||||
this.longPressTimer = undefined;
|
|
||||||
this.suppressNextClick = true;
|
|
||||||
this.openMenu(target);
|
|
||||||
}, REFRESH_LONG_PRESS_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly onRefreshContextMenu = (event: MouseEvent): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.clearLongPressTimer();
|
|
||||||
this.suppressNextClick = true;
|
|
||||||
this.openMenu(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly onDocumentClick = (event: MouseEvent): void => {
|
|
||||||
const path = event.composedPath();
|
|
||||||
if (path.includes(this) || (this.menuPortal !== undefined && path.includes(this.menuPortal))) return;
|
|
||||||
this.closeMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly onDocumentKeyDown = (event: KeyboardEvent): void => {
|
|
||||||
if (event.key !== "Escape" || !this.menuOpen) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.closeMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
private openMenu(target: EventTarget | null): void {
|
|
||||||
this.menuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" });
|
|
||||||
this.menuOpen = true;
|
|
||||||
this.renderPortalMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
private closeMenu(): void {
|
|
||||||
this.menuOpen = false;
|
|
||||||
this.suppressNextClick = false;
|
|
||||||
this.removePortalMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
private refresh(): void {
|
|
||||||
this.closeMenu();
|
|
||||||
void this.onRefresh?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
private reload(): void {
|
|
||||||
this.closeMenu();
|
|
||||||
this.onReload?.();
|
this.onReload?.();
|
||||||
}
|
|
||||||
|
|
||||||
private clearLongPressTimer(): void {
|
|
||||||
if (this.longPressTimer === undefined) return;
|
|
||||||
window.clearTimeout(this.longPressTimer);
|
|
||||||
this.longPressTimer = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderPortalMenu(): void {
|
|
||||||
const ownerDocument = this.ownerDocument;
|
|
||||||
ensurePortalMenuStyles(ownerDocument);
|
|
||||||
|
|
||||||
const menu = this.menuPortal ?? ownerDocument.createElement("div");
|
|
||||||
this.menuPortal = menu;
|
|
||||||
menu.className = "pi-web-app-refresh-menu-portal";
|
|
||||||
menu.setAttribute("role", "menu");
|
|
||||||
menu.setAttribute("style", this.menuStyle);
|
|
||||||
menu.replaceChildren(
|
|
||||||
this.createPortalMenuButton("Refresh app data", () => { this.refresh(); }),
|
|
||||||
this.createPortalMenuButton("Full page reload", () => { this.reload(); }),
|
|
||||||
);
|
|
||||||
menu.addEventListener("click", this.onPortalMenuClick);
|
|
||||||
if (!menu.isConnected) ownerDocument.body.append(menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createPortalMenuButton(label: string, onClick: () => void): HTMLButtonElement {
|
|
||||||
const button = this.ownerDocument.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.setAttribute("role", "menuitem");
|
|
||||||
button.textContent = label;
|
|
||||||
button.addEventListener("click", (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onClick();
|
|
||||||
});
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
private removePortalMenu(): void {
|
|
||||||
this.menuPortal?.removeEventListener("click", this.onPortalMenuClick);
|
|
||||||
this.menuPortal?.remove();
|
|
||||||
this.menuPortal = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly onPortalMenuClick = (event: MouseEvent): void => {
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
@@ -176,52 +38,5 @@ export class AppRefreshControl extends LitElement {
|
|||||||
:host, :host * { -webkit-user-select: none; user-select: none; }
|
:host, :host * { -webkit-user-select: none; user-select: none; }
|
||||||
.app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; }
|
.app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; }
|
||||||
.app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
.app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||||
.app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; }
|
|
||||||
@keyframes app-refresh-spin { to { transform: rotate(360deg); } }
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensurePortalMenuStyles(ownerDocument: Document): void {
|
|
||||||
if (ownerDocument.getElementById(REFRESH_MENU_PORTAL_STYLE_ID) !== null) return;
|
|
||||||
const style = ownerDocument.createElement("style");
|
|
||||||
style.id = REFRESH_MENU_PORTAL_STYLE_ID;
|
|
||||||
style.textContent = `
|
|
||||||
.pi-web-app-refresh-menu-portal {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 2147483647;
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-width: min(170px, calc(100vw - 16px));
|
|
||||||
overflow: auto;
|
|
||||||
padding: 4px;
|
|
||||||
border: 1px solid var(--pi-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--pi-surface);
|
|
||||||
color: var(--pi-text);
|
|
||||||
box-shadow: 0 8px 24px var(--pi-shadow);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font: 14px system-ui, sans-serif;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.pi-web-app-refresh-menu-portal button {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--pi-text);
|
|
||||||
padding: 7px 9px;
|
|
||||||
text-align: left;
|
|
||||||
white-space: normal;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.pi-web-app-refresh-menu-portal button:hover,
|
|
||||||
.pi-web-app-refresh-menu-portal button:focus {
|
|
||||||
background: var(--pi-selection-bg);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
ownerDocument.head.append(style);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -75,14 +75,6 @@ export const appStyles = css`
|
|||||||
.context-item { flex: 0 0 auto; min-width: 0; display: flex; }
|
.context-item { flex: 0 0 auto; min-width: 0; display: flex; }
|
||||||
.context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; }
|
.context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; }
|
||||||
.context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; }
|
.context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; }
|
||||||
.app-refresh { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
|
|
||||||
.app-refresh, .app-refresh * { -webkit-user-select: none; user-select: none; }
|
|
||||||
.app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 999px; padding: 0; line-height: 1; touch-action: manipulation; -webkit-touch-callout: none; }
|
|
||||||
.app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
|
||||||
.app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; }
|
|
||||||
.app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; }
|
|
||||||
.app-refresh-menu button { display: block; width: 100%; border: 0; background: transparent; color: var(--pi-text); text-align: left; white-space: normal; overflow-wrap: anywhere; }
|
|
||||||
.app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); }
|
|
||||||
.context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; }
|
.context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; }
|
||||||
.context-chip:hover { background: var(--pi-surface-hover); }
|
.context-chip:hover { background: var(--pi-surface-hover); }
|
||||||
.context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
|
.context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
|
||||||
@@ -155,7 +147,6 @@ export const appStyles = css`
|
|||||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||||
.empty { margin: auto; color: var(--pi-muted); }
|
.empty { margin: auto; color: var(--pi-muted); }
|
||||||
.error { padding: 10px 16px; border-bottom: 1px solid var(--pi-border); color: var(--pi-danger); }
|
.error { padding: 10px 16px; border-bottom: 1px solid var(--pi-border); color: var(--pi-danger); }
|
||||||
@keyframes app-refresh-spin { to { transform: rotate(360deg); } }
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const workspacePanelStyles = css`
|
export const workspacePanelStyles = css`
|
||||||
|
|||||||
@@ -87,13 +87,6 @@ export function createCoreActions(): PluginAction[] {
|
|||||||
group: "Preferences",
|
group: "Preferences",
|
||||||
run: (context) => { context.piWebUnstable?.openSettings?.(); },
|
run: (context) => { context.piWebUnstable?.openSettings?.(); },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "app.refresh-data",
|
|
||||||
title: "Refresh App Data",
|
|
||||||
description: "Refresh session, status, activity, and the current workspace surface without reloading the page",
|
|
||||||
group: "General",
|
|
||||||
run: (context) => context.refreshAppData(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "app.reload-page",
|
id: "app.reload-page",
|
||||||
title: "Full Page Reload",
|
title: "Full Page Reload",
|
||||||
|
|||||||
@@ -174,17 +174,17 @@ describe("PluginRegistry", () => {
|
|||||||
expect(calls).toEqual(["refreshGit"]);
|
expect(calls).toEqual(["refreshGit"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes app refresh, reload, and settings actions through the runtime context", () => {
|
it("routes app reload and settings actions through the runtime context", () => {
|
||||||
const registry = new PluginRegistry();
|
const registry = new PluginRegistry();
|
||||||
registry.register({ id: "core", plugin: corePlugin });
|
registry.register({ id: "core", plugin: corePlugin });
|
||||||
const { context, calls } = createContext();
|
const { context, calls } = createContext();
|
||||||
const actions = registry.getActions(context);
|
const actions = registry.getActions(context);
|
||||||
|
|
||||||
void actions.find((candidate) => candidate.id === "core:app.refresh-data")?.run();
|
expect(actions.some((candidate) => candidate.id === "core:app.refresh-data")).toBe(false);
|
||||||
void actions.find((candidate) => candidate.id === "core:app.reload-page")?.run();
|
void actions.find((candidate) => candidate.id === "core:app.reload-page")?.run();
|
||||||
void actions.find((candidate) => candidate.id === "core:settings.open")?.run();
|
void actions.find((candidate) => candidate.id === "core:settings.open")?.run();
|
||||||
|
|
||||||
expect(calls).toEqual(["refreshAppData", "reloadPage", "openSettings"]);
|
expect(calls).toEqual(["reloadPage", "openSettings"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes terminal navigation as a shortcut-backed action", () => {
|
it("exposes terminal navigation as a shortcut-backed action", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user