v0.4.0 f6d8405

desktop.svelte.ts RawHistoryFolder

1/**
2 * Desktop window manager.
3 *
4 * Every window on the XP desktop (buddy list, chat rooms, IMs, dialogs) is
5 * registered here so the taskbar, focus order and z-index are consistent.
6 */
7import type { Id } from '../../convex/_generated/dataModel';
8
9const SHOW_TIMESTAMPS_KEY = 'pdraim-show-timestamps';
10
11export type WindowKind =
12	| 'buddy-list'
13	| 'chat-room'
14	| 'im'
15	| 'login'
16	| 'profile'
17	| 'away'
18	| 'new-room'
19	| 'preferences'
20	| 'about';
21
22export interface DesktopWindow {
23	id: string;
24	kind: WindowKind;
25	title: string;
26	icon: string;
27	z: number;
28	minimized: boolean;
29	/** Set when a window wants the taskbar button to blink (new IM). */
30	attention: boolean;
31	/** Kind-specific payload */
32	props: Record<string, unknown>;
33}
34
35export interface ImWindowProps {
36	roomId: Id<'chatRooms'>;
37	otherUserId: Id<'users'>;
38	otherNickname: string;
39}
40
41export interface ChatRoomTab {
42	roomId: Id<'chatRooms'> | null; // null = default room
43	roomName: string;
44}
45
46export const CHAT_WINDOW_ID = 'chat-rooms';
47
48export interface ProfileWindowProps {
49	userId: Id<'users'>;
50	nickname: string;
51}
52
53class DesktopState {
54	windows = $state<DesktopWindow[]>([]);
55	focusedId = $state<string | null>(null);
56	private nextZ = 10;
57
58	/** Open chat room tabs (all shown in the single chat-room window). */
59	roomTabs = $state<ChatRoomTab[]>([]);
60	activeRoomId = $state<Id<'chatRooms'> | null>(null);
61
62	/** Whether the login "sign on" sequence is running (modem animation). */
63	signingOn = $state(false);
64
65	/** Preferences: show message times in IM windows. */
66	showTimestamps = $state(
67		typeof localStorage === 'undefined' || localStorage.getItem(SHOW_TIMESTAMPS_KEY) !== 'false'
68	);
69
70	setShowTimestamps(value: boolean) {
71		this.showTimestamps = value;
72		localStorage.setItem(SHOW_TIMESTAMPS_KEY, String(value));
73	}
74
75	focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null);
76	taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id)));
77
78	get(id: string) {
79		return this.windows.find((w) => w.id === id) ?? null;
80	}
81
82	has(id: string) {
83		return this.windows.some((w) => w.id === id);
84	}
85
86	/**
87	 * Open (or focus if already open) a window.
88	 * Windows are identified by a stable id so that e.g. an IM with a given
89	 * user is never opened twice.
90	 */
91	open(win: Omit<DesktopWindow, 'z' | 'minimized' | 'attention'>) {
92		const existing = this.get(win.id);
93		if (existing) {
94			existing.title = win.title;
95			existing.props = win.props;
96			this.focus(win.id);
97			return existing;
98		}
99		const created: DesktopWindow = {
100			...win,
101			z: ++this.nextZ,
102			minimized: false,
103			attention: false
104		};
105		this.windows = [...this.windows, created];
106		this.focusedId = created.id;
107		return created;
108	}
109
110	close(id: string) {
111		if (id === CHAT_WINDOW_ID) {
112			this.roomTabs = [];
113			this.activeRoomId = null;
114		}
115		this.windows = this.windows.filter((w) => w.id !== id);
116		if (this.focusedId === id) {
117			const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
118			this.focusedId = top?.id ?? null;
119		}
120	}
121
122	focus(id: string) {
123		const win = this.get(id);
124		if (!win) return;
125		win.minimized = false;
126		win.attention = false;
127		win.z = ++this.nextZ;
128		this.focusedId = id;
129	}
130
131	minimize(id: string) {
132		const win = this.get(id);
133		if (!win) return;
134		win.minimized = true;
135		if (this.focusedId === id) {
136			const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
137			this.focusedId = top?.id ?? null;
138		}
139	}
140
141	toggleFromTaskbar(id: string) {
142		const win = this.get(id);
143		if (!win) return;
144		if (win.minimized || this.focusedId !== id) {
145			this.focus(id);
146		} else {
147			this.minimize(id);
148		}
149	}
150
151	requestAttention(id: string) {
152		const win = this.get(id);
153		if (win && this.focusedId !== id) win.attention = true;
154	}
155
156	blurAll() {
157		this.focusedId = null;
158	}
159
160	// ---------- Convenience openers ----------
161
162	openBuddyList() {
163		return this.open({
164			id: 'buddy-list',
165			kind: 'buddy-list',
166			title: 'Liste de contacts',
167			icon: '/aim/running-man-16.png',
168			props: {}
169		});
170	}
171
172	/**
173	 * Chat rooms live as tabs inside one "Salon" window. Opening a room adds
174	 * (or selects) its tab and brings the window forward.
175	 */
176	openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {
177		// General is the default room (tab id null); group names are unique case-insensitively,
178		// so opening it by concrete id must reuse that tab instead of duplicating it.
179		if (roomName.toLowerCase() === 'general') {
180			roomId = null;
181			roomName = 'General';
182		}
183		if (!this.roomTabs.some((t) => t.roomId === roomId)) {
184			this.roomTabs = [...this.roomTabs, { roomId, roomName }];
185		}
186		this.activeRoomId = roomId;
187		return this.open({
188			id: CHAT_WINDOW_ID,
189			kind: 'chat-room',
190			title: `Salon : ${roomName}`,
191			icon: '/aim/chat-room-16.png',
192			props: {}
193		});
194	}
195
196	selectRoomTab(roomId: Id<'chatRooms'> | null) {
197		const tab = this.roomTabs.find((t) => t.roomId === roomId);
198		if (!tab) return;
199		this.activeRoomId = roomId;
200		const win = this.get(CHAT_WINDOW_ID);
201		if (win) win.title = `Salon : ${tab.roomName}`;
202	}
203
204	closeRoomTab(roomId: Id<'chatRooms'> | null) {
205		const index = this.roomTabs.findIndex((t) => t.roomId === roomId);
206		if (index === -1) return;
207		this.roomTabs = this.roomTabs.filter((t) => t.roomId !== roomId);
208		if (this.roomTabs.length === 0) {
209			this.close(CHAT_WINDOW_ID);
210			return;
211		}
212		if (this.activeRoomId === roomId) {
213			const next = this.roomTabs[Math.min(index, this.roomTabs.length - 1)];
214			this.selectRoomTab(next.roomId);
215		}
216	}
217
218	openIm(props: ImWindowProps) {
219		return this.open({
220			id: `im-${props.roomId}`,
221			kind: 'im',
222			title: `${props.otherNickname} - Message instantané`,
223			icon: '/aim/running-man-16.png',
224			props: props as unknown as Record<string, unknown>
225		});
226	}
227
228	openLogin(tab: 'signin' | 'signup' = 'signin') {
229		return this.open({
230			id: 'login',
231			kind: 'login',
232			title: 'Connexion',
233			icon: '/aim/running-man-16.png',
234			props: { tab }
235		});
236	}
237
238	openProfile(props: ProfileWindowProps) {
239		return this.open({
240			id: `profile-${props.userId}`,
241			kind: 'profile',
242			title: `Infos sur ${props.nickname}`,
243			icon: '/aim/xp-info-16.png',
244			props: props as unknown as Record<string, unknown>
245		});
246	}
247
248	openAway() {
249		return this.open({
250			id: 'away',
251			kind: 'away',
252			title: 'Message d’absence',
253			icon: '/aim/xp-note-16.png',
254			props: {}
255		});
256	}
257
258	openNewRoom() {
259		return this.open({
260			id: 'new-room',
261			kind: 'new-room',
262			title: 'Nouveau salon',
263			icon: '/aim/chat-room-16.png',
264			props: {}
265		});
266	}
267
268	openPreferences() {
269		return this.open({
270			id: 'preferences',
271			kind: 'preferences',
272			title: 'Préférences',
273			icon: '/aim/xp-control-panel-16.png',
274			props: {}
275		});
276	}
277
278	openAbout() {
279		return this.open({
280			id: 'about',
281			kind: 'about',
282			title: 'À propos de PDR AIM',
283			icon: '/aim/xp-help-16.png',
284			props: {}
285		});
286	}
287}
288
289export const desktop = new DesktopState();

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 8

↑↓ moveenter openesc close