main fe6ee00

xp-window.svelte RawHistoryFolder

1<script lang="ts">
2	/**
3	 * Generic Windows XP window shell driven by the desktop window manager.
4	 * Handles: drag, optional resize, focus/z-index, minimize/close buttons,
5	 * mobile full-screen fallback. Content goes in the default snippet.
6	 */
7	import type { Snippet } from 'svelte';
8	import { onMount } from 'svelte';
9	import { draggable } from '$lib/actions/draggable';
10	import { resizable } from '$lib/actions/resizable';
11	import { desktop } from '$lib/states/desktop.svelte';
12
13	let {
14		id,
15		width = 420,
16		height = 320,
17		minWidth = 240,
18		minHeight = 160,
19		x,
20		y,
21		resizableWindow = true,
22		showMaximize = false,
23		bodyClass = '',
24		children,
25		menu,
26		onClose
27	} = $props<{
28		id: string;
29		width?: number;
30		height?: number;
31		minWidth?: number;
32		minHeight?: number;
33		x?: number;
34		y?: number;
35		resizableWindow?: boolean;
36		showMaximize?: boolean;
37		bodyClass?: string;
38		children: Snippet;
39		menu?: Snippet;
40		onClose?: () => void;
41	}>();
42
43	const win = $derived(desktop.get(id));
44	const focused = $derived(desktop.focusedId === id);
45
46	// svelte-ignore state_referenced_locally
47	let w = $state(width);
48	// svelte-ignore state_referenced_locally
49	let h = $state(height);
50	// svelte-ignore state_referenced_locally
51	let left = $state(x ?? 0);
52	// svelte-ignore state_referenced_locally
53	let top = $state(y ?? 0);
54	let isMobile = $state(false);
55	let isMaximized = $state(false);
56	let restore: { w: number; h: number; left: number; top: number } | null = null;
57
58	onMount(() => {
59		isMobile = window.innerWidth <= 768;
60		if (x === undefined || y === undefined) {
61			// Cascade new windows slightly from the centre so they do not stack exactly
62			const offset = (desktop.windows.length % 6) * 24;
63			left = Math.max(8, Math.round((window.innerWidth - w) / 2) + offset);
64			top = Math.max(8, Math.round((window.innerHeight - h) / 2 - 40) + offset);
65		}
66		const onResize = () => {
67			isMobile = window.innerWidth <= 768;
68		};
69		window.addEventListener('resize', onResize);
70		return () => window.removeEventListener('resize', onResize);
71	});
72
73	// Follow explicit size changes from the parent (e.g. a dialog switching tabs)
74	$effect(() => {
75		if (isMaximized) return;
76		w = width;
77		h = height;
78	});
79
80	function close() {
81		onClose?.();
82		desktop.close(id);
83	}
84
85	function minimize(e: MouseEvent) {
86		e.stopPropagation();
87		desktop.minimize(id);
88	}
89
90	function toggleMaximize(e?: MouseEvent) {
91		e?.stopPropagation();
92		if (isMobile) return;
93		if (isMaximized && restore) {
94			w = restore.w;
95			h = restore.h;
96			left = restore.left;
97			top = restore.top;
98			isMaximized = false;
99		} else {
100			restore = { w, h, left, top };
101			left = 4;
102			top = 4;
103			w = window.innerWidth - 8;
104			h = window.innerHeight - 8 - 30; // leave room for the taskbar
105			isMaximized = true;
106		}
107	}
108</script>
109
110{#if win}
111	<div
112		class="window xp-window"
113		class:inactive={!focused}
114		class:hidden={win.minimized}
115		class:mobile={isMobile}
116		style="width: {w}px; height: {h}px; left: {left}px; top: {top}px; z-index: {win.z};"
117		role="dialog"
118		aria-label={win.title}
119		tabindex="-1"
120		onmousedown={() => desktop.focus(id)}
121		use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
122		ondragmove={(e) => {
123			left = e.detail.x;
124			top = e.detail.y;
125		}}
126		use:resizable={{
127			enabled: resizableWindow && !isMobile && !isMaximized,
128			minWidth,
129			minHeight,
130			maxWidth: typeof window !== 'undefined' ? window.innerWidth - 16 : 1600,
131			maxHeight: typeof window !== 'undefined' ? window.innerHeight - 40 : 1200
132		}}
133		onresizemove={(e) => {
134			w = Math.max(minWidth, e.detail.width);
135			h = Math.max(minHeight, e.detail.height);
136		}}
137	>
138		<div class="title-bar" ondblclick={() => showMaximize && toggleMaximize()} role="presentation">
139			<div class="title-bar-text">
140				<img src={win.icon} alt="" width="16" height="16" class="title-icon" />
141				<span class="title-text">{win.title}</span>
142			</div>
143			<div class="title-bar-controls">
144				<button aria-label="Minimize" onclick={minimize}></button>
145				{#if showMaximize && !isMobile}
146					<button aria-label={isMaximized ? 'Restore' : 'Maximize'} onclick={toggleMaximize}
147					></button>
148				{/if}
149				<button aria-label="Close" onclick={close}></button>
150			</div>
151		</div>
152		{#if menu}
153			<div class="xp-menubar">{@render menu()}</div>
154		{/if}
155		<div class="window-body xp-body {bodyClass}" class:has-menu={Boolean(menu)}>
156			{@render children()}
157		</div>
158	</div>
159{/if}
160
161<style>
162	.xp-window {
163		position: fixed;
164		box-sizing: border-box;
165		display: flex;
166		flex-direction: column;
167		font-family: Tahoma, 'Pixelated MS Sans Serif', Arial, sans-serif;
168		box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.35);
169	}
170
171	.xp-window.hidden {
172		display: none;
173	}
174
175	.xp-window.inactive {
176		box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
177	}
178
179	/* xp.css already styles active/inactive gradients through :not(:focus-within);
180	   force the inactive look based on our own focus tracking instead. */
181	.xp-window.inactive .title-bar {
182		background: linear-gradient(
183			180deg,
184			#7697e7 0%,
185			#7e9ee3 8%,
186			#94afec 40%,
187			#97b4e9 88%,
188			#82a5e4 100%
189		);
190	}
191
192	.title-bar {
193		cursor: move;
194		user-select: none;
195		display: flex;
196		align-items: center;
197		flex: 0 0 auto;
198		height: 30px;
199		box-sizing: border-box;
200		padding: 0 5px 0 4px;
201		font-size: 13px;
202	}
203
204	.title-bar-text {
205		display: flex;
206		align-items: center;
207		gap: 5px;
208		white-space: nowrap;
209		overflow: hidden;
210		text-overflow: ellipsis;
211		min-width: 0;
212		flex: 1;
213		margin-right: 8px;
214	}
215
216	.title-bar-controls {
217		display: flex;
218		align-items: center;
219		gap: 2px;
220		flex: 0 0 auto;
221	}
222
223	.title-bar-controls button {
224		width: 21px;
225		height: 21px;
226		min-width: 21px;
227		min-height: 21px;
228		margin: 0;
229		flex: 0 0 21px;
230	}
231
232	.title-text {
233		overflow: hidden;
234		text-overflow: ellipsis;
235	}
236
237	.title-icon {
238		flex: 0 0 16px;
239	}
240
241	.xp-menubar {
242		display: flex;
243		gap: 2px;
244		padding: 1px 4px;
245		background: #ece9d8;
246		border-bottom: 1px solid #aca899;
247		font-size: 11px;
248		flex: 0 0 auto;
249	}
250
251	.xp-menubar :global(button) {
252		border: 1px solid transparent;
253		background: transparent;
254		box-shadow: none;
255		min-width: 0;
256		padding: 2px 6px;
257		font-size: 11px;
258		border-radius: 0;
259	}
260
261	.xp-menubar :global(button:hover) {
262		background: #316ac5;
263		color: white;
264	}
265
266	.xp-body {
267		flex: 1 1 auto;
268		min-height: 0;
269		display: flex;
270		flex-direction: column;
271		margin: 0;
272		padding: 6px;
273		overflow: hidden;
274	}
275
276	:global(.xp-window .resize-handle) {
277		position: absolute;
278		bottom: 0;
279		right: 0;
280		width: 15px !important;
281		height: 15px !important;
282		cursor: se-resize !important;
283		background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23555'/%3E%3C/svg%3E");
284		background-position: bottom right;
285		background-repeat: no-repeat;
286	}
287
288	@media (max-width: 768px) {
289		.xp-window.mobile {
290			left: 0 !important;
291			top: 0 !important;
292			width: 100vw !important;
293			height: calc(100vh - 30px) !important;
294			border-radius: 0;
295		}
296	}
297</style>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 9

↑↓ moveenter openesc close