Skip to content

Commit dc6890b

Browse files
committed
feat(desktop): add a setting to hide unused sidebar views (#1115)
1 parent c76c6a3 commit dc6890b

17 files changed

Lines changed: 216 additions & 4 deletions

File tree

apps/desktop/src/components/Layout.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,3 +1007,24 @@ describe('Layout sync security warning', () => {
10071007
}
10081008
});
10091009
});
1010+
1011+
describe('Sidebar hidden views (#1115)', () => {
1012+
it('omits hidden entries and drops fully hidden sections, keeping structural items', () => {
1013+
act(() => {
1014+
useUiStore.getState().setSidebarViewHidden('someday', true);
1015+
useUiStore.getState().setSidebarViewHidden('done', true);
1016+
useUiStore.getState().setSidebarViewHidden('archived', true);
1017+
useUiStore.getState().setSidebarViewHidden('trash', true);
1018+
});
1019+
const { container } = renderLayout();
1020+
const ids = Array.from(container.querySelectorAll('[data-sidebar-item]'))
1021+
.map((el) => el.getAttribute('data-view'));
1022+
expect(ids).not.toContain('someday');
1023+
expect(ids).not.toContain('done');
1024+
expect(ids).toContain('inbox');
1025+
expect(ids).toContain('waiting');
1026+
// Every Archive entry is hidden, so its section header disappears too.
1027+
expect(container.querySelector('#sidebar-section-archive')).toBeNull();
1028+
expect(container.querySelector('#sidebar-section-lists')).not.toBeNull();
1029+
});
1030+
});

apps/desktop/src/components/Layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export function Layout({ children, currentView, onViewChange, onOpenSyncSettings
128128
const isCollapsed = userSidebarCollapsed || compactViewport;
129129
const calendarDragNavTimeoutRef = useRef<number | null>(null);
130130
const isFocusMode = useUiStore((state) => state.isFocusMode);
131+
const hiddenSidebarViews = useUiStore((state) => state.hiddenSidebarViews);
131132
const showToast = useUiStore((state) => state.showToast);
132133
const isObsidianEnabled = useObsidianStore((state) => state.config.enabled);
133134
// Timeline is opt-in (#1111): hidden from navigation until it is switched on.
@@ -367,7 +368,12 @@ export function Layout({ children, currentView, onViewChange, onOpenSyncSettings
367368
{ id: 'trash', labelKey: 'nav.trash', icon: Trash2, tone: 'recessed' },
368369
],
369370
},
370-
]), [inboxCount, isObsidianEnabled, isTimelineEnabled, t]);
371+
] satisfies NavSection[])
372+
.map((section) => ({
373+
...section,
374+
items: section.items.filter((item) => !hiddenSidebarViews.includes(item.id as (typeof hiddenSidebarViews)[number])),
375+
}))
376+
.filter((section) => section.items.length > 0), [hiddenSidebarViews, inboxCount, isObsidianEnabled, isTimelineEnabled, t]);
371377

372378
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() => loadCollapsedSections());
373379

apps/desktop/src/components/views/settings/SettingsMainPage.tsx

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import {
44
GLOBAL_QUICK_ADD_SHORTCUT_DISABLED,
55
getGlobalQuickAddShortcutOptions,
66
} from '../../../lib/global-quick-add-shortcut';
7-
import { getLocaleCoverageTier, normalizeWeekStartSetting } from '@mindwtr/core';
7+
import { getLocaleCoverageTier, normalizeWeekStartSetting, resolveFeatureFlags, useTaskStore } from '@mindwtr/core';
88
import type { DesktopThemeMode } from '../../../lib/theme';
99
import { Switch } from '../../ui/Switch';
10-
import { SettingRow, SettingsCard, SettingsSectionHeader } from './SettingRow';
10+
import { SettingField, SettingRow, SettingsCard, SettingsSectionHeader } from './SettingRow';
11+
import { useUiStore } from '../../../store/ui-store';
12+
import { HIDEABLE_SIDEBAR_VIEW_IDS, type HideableSidebarViewId } from '../../../lib/sidebar-views';
1113

1214
const FLATPAK_QUICK_ADD_COMMAND = 'flatpak run tech.dongdongbh.mindwtr --quick-add';
1315

@@ -38,6 +40,20 @@ type Labels = {
3840
textSizeExtraLarge: string;
3941
showTaskAge: string;
4042
showTaskAgeDesc: string;
43+
sidebarViews: string;
44+
sidebarViewsDesc: string;
45+
navAgenda: string;
46+
navSomeday: string;
47+
navWaiting: string;
48+
navReference: string;
49+
navCalendar: string;
50+
navReview: string;
51+
navContexts: string;
52+
navBoard: string;
53+
navTimeline: string;
54+
navDone: string;
55+
navArchived: string;
56+
navTrash: string;
4157
system: string;
4258
light: string;
4359
dark: string;
@@ -210,6 +226,27 @@ export function SettingsMainPage({
210226
? t.weekStartSunday
211227
: t.weekStartSystem;
212228

229+
const hiddenSidebarViews = useUiStore((state) => state.hiddenSidebarViews);
230+
const setSidebarViewHidden = useUiStore((state) => state.setSidebarViewHidden);
231+
const timelineEnabled = useTaskStore((state) => resolveFeatureFlags(state.settings).timeline);
232+
const sidebarViewLabels: Record<HideableSidebarViewId, string> = {
233+
agenda: t.navAgenda,
234+
someday: t.navSomeday,
235+
waiting: t.navWaiting,
236+
reference: t.navReference,
237+
calendar: t.navCalendar,
238+
review: t.navReview,
239+
contexts: t.navContexts,
240+
board: t.navBoard,
241+
timeline: t.navTimeline,
242+
done: t.navDone,
243+
archived: t.navArchived,
244+
trash: t.navTrash,
245+
};
246+
const sidebarViewOptions = HIDEABLE_SIDEBAR_VIEW_IDS
247+
.filter((id) => id !== 'timeline' || timelineEnabled)
248+
.map((id) => ({ id, label: sidebarViewLabels[id] }));
249+
213250
return (
214251
<div className="space-y-5">
215252
{/* Look & Feel */}
@@ -269,6 +306,26 @@ export function SettingsMainPage({
269306
onCheckedChange={() => onShowTaskAgeChange(!showTaskAge)}
270307
/>
271308
</SettingRow>
309+
<SettingField
310+
settingsKey="sidebarViews"
311+
title={t.sidebarViews}
312+
description={t.sidebarViewsDesc}
313+
className="p-4 gap-3"
314+
>
315+
<div className="grid grid-cols-2 gap-x-4 gap-y-2 md:grid-cols-3">
316+
{sidebarViewOptions.map((view) => (
317+
<label key={view.id} className="flex items-center gap-2 text-sm text-foreground">
318+
<input
319+
type="checkbox"
320+
className="h-4 w-4 accent-primary"
321+
checked={!hiddenSidebarViews.includes(view.id)}
322+
onChange={(e) => setSidebarViewHidden(view.id, !e.target.checked)}
323+
/>
324+
<span className="truncate">{view.label}</span>
325+
</label>
326+
))}
327+
</div>
328+
</SettingField>
272329
</SettingsCard>
273330

274331
{/* Localization */}

apps/desktop/src/components/views/settings/labels.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ export const SETTINGS_LABEL_KEYS = [
3434
'textSizeExtraLarge',
3535
'showTaskAge',
3636
'showTaskAgeDesc',
37+
'sidebarViews',
38+
'sidebarViewsDesc',
39+
'navAgenda',
40+
'navSomeday',
41+
'navWaiting',
42+
'navReference',
43+
'navCalendar',
44+
'navReview',
45+
'navContexts',
46+
'navBoard',
47+
'navTimeline',
48+
'navDone',
49+
'navArchived',
50+
'navTrash',
3751
'gtd',
3852
'gtdDesc',
3953
'settingsSaveFailed',
@@ -674,6 +688,20 @@ export type SettingsLabels = Record<(typeof SETTINGS_LABEL_KEYS)[number], string
674688
// of the app (e.g. `back` -> `common.back`) or core spells it differently.
675689
export const labelKeyOverrides: Partial<Record<keyof SettingsLabels, string>> = {
676690
back: 'common.back',
691+
// Sidebar-view checkboxes reuse the sidebar's own labels so every locale
692+
// is covered without new strings.
693+
navAgenda: 'nav.agenda',
694+
navSomeday: 'nav.someday',
695+
navWaiting: 'nav.waiting',
696+
navReference: 'nav.reference',
697+
navCalendar: 'nav.calendar',
698+
navReview: 'nav.review',
699+
navContexts: 'nav.contexts',
700+
navBoard: 'nav.board',
701+
navTimeline: 'nav.timeline',
702+
navDone: 'nav.done',
703+
navArchived: 'nav.archived',
704+
navTrash: 'nav.trash',
677705
settingsSaveFailed: 'settings.feedback.saveFailed',
678706
pomodoroAutoStartNotice: 'settings.gtdMobile.pomodoroWillNowAdvancePhasesAutomatically',
679707
// "Auto" is already translated for text direction; the feed color picker
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Sidebar entries the user can hide from Settings -> General (#1115). This is
2+
// a device-local presentation choice: hiding an entry never disables the view
3+
// itself, which stays reachable through search, keybindings, and task status.
4+
// Inbox and Projects are deliberately absent — they are the structural core of
5+
// the workflow and always render. Obsidian is absent because its own
6+
// integration toggle already controls its sidebar presence, and Timeline is
7+
// only offered while the timeline feature flag is on.
8+
export const HIDEABLE_SIDEBAR_VIEW_IDS = [
9+
'agenda',
10+
'someday',
11+
'waiting',
12+
'reference',
13+
'calendar',
14+
'review',
15+
'contexts',
16+
'board',
17+
'timeline',
18+
'done',
19+
'archived',
20+
'trash',
21+
] as const;
22+
23+
export type HideableSidebarViewId = (typeof HIDEABLE_SIDEBAR_VIEW_IDS)[number];
24+
25+
export const HIDDEN_SIDEBAR_VIEWS_STORAGE_KEY = 'mindwtr:sidebar:hiddenViews:v1';
26+
27+
const HIDEABLE_ID_SET: ReadonlySet<string> = new Set(HIDEABLE_SIDEBAR_VIEW_IDS);
28+
29+
// Drops ids outside the roster so a stale or hand-edited blob can never hide a
30+
// structural entry like Inbox.
31+
export function sanitizeHiddenSidebarViews(value: unknown): HideableSidebarViewId[] {
32+
if (!Array.isArray(value)) return [];
33+
return [...new Set(value.filter((id): id is HideableSidebarViewId => typeof id === 'string' && HIDEABLE_ID_SET.has(id)))];
34+
}

apps/desktop/src/store/ui-store.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,20 @@ describe('useUiStore project layouts (#1019)', () => {
251251
});
252252
});
253253
});
254+
255+
describe('useUiStore hidden sidebar views', () => {
256+
it('hydrates only hideable roster ids and persists changes', async () => {
257+
window.localStorage.clear();
258+
vi.resetModules();
259+
window.localStorage.setItem('mindwtr:sidebar:hiddenViews:v1', JSON.stringify(['someday', 'inbox', 42]));
260+
const { useUiStore } = await import('./ui-store');
261+
// 'inbox' is structural and 42 is junk — neither may hydrate.
262+
expect(useUiStore.getState().hiddenSidebarViews).toEqual(['someday']);
263+
264+
useUiStore.getState().setSidebarViewHidden('board', true);
265+
expect(JSON.parse(window.localStorage.getItem('mindwtr:sidebar:hiddenViews:v1')!)).toEqual(['someday', 'board']);
266+
267+
useUiStore.getState().setSidebarViewHidden('someday', false);
268+
expect(useUiStore.getState().hiddenSidebarViews).toEqual(['board']);
269+
});
270+
});

apps/desktop/src/store/ui-store.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createWithEqualityFn } from 'zustand/traditional';
22
import { useTaskStore, type FilterCriteria, type TaskSortBy } from '@mindwtr/core';
33
import { DONE_AXES, FOCUS_AXES, REFERENCE_AXES, SOMEDAY_AXES, sanitizeAxis, type DoneGroupBy, type NextGroupBy, type ReferenceGroupBy, type SomedayGroupBy } from '../components/views/list/next-grouping';
4+
import { HIDDEN_SIDEBAR_VIEWS_STORAGE_KEY, sanitizeHiddenSidebarViews, type HideableSidebarViewId } from '../lib/sidebar-views';
45
import { DONE_SORT_OPTIONS } from '../lib/task-list-sort';
56

67
const toastTimeouts = new Map<string, number>();
@@ -148,6 +149,28 @@ function pruneProjectLayouts(layouts: Record<string, ProjectLayout>): Record<str
148149
return pruned;
149150
}
150151

152+
function readStoredHiddenSidebarViews(): HideableSidebarViewId[] {
153+
const storage = getPersistentStorage();
154+
if (!storage) return [];
155+
try {
156+
const raw = storage.getItem(HIDDEN_SIDEBAR_VIEWS_STORAGE_KEY);
157+
if (!raw) return [];
158+
return sanitizeHiddenSidebarViews(JSON.parse(raw));
159+
} catch {
160+
return [];
161+
}
162+
}
163+
164+
function saveStoredHiddenSidebarViews(views: HideableSidebarViewId[]) {
165+
const storage = getPersistentStorage();
166+
if (!storage) return;
167+
try {
168+
storage.setItem(HIDDEN_SIDEBAR_VIEWS_STORAGE_KEY, JSON.stringify(views));
169+
} catch {
170+
// Sidebar visibility is convenience state; storage failures should not block UI updates.
171+
}
172+
}
173+
151174
function saveStoredProjectLayouts(layouts: Record<string, ProjectLayout>) {
152175
const storage = getPersistentStorage();
153176
if (!storage) return;
@@ -199,6 +222,8 @@ interface UiState {
199222
setProjectView: (partial: Partial<UiState['projectView']>) => void;
200223
projectLayouts: Record<string, ProjectLayout>;
201224
setProjectLayout: (projectId: string, layout: ProjectLayout) => void;
225+
hiddenSidebarViews: HideableSidebarViewId[];
226+
setSidebarViewHidden: (viewId: HideableSidebarViewId, hidden: boolean) => void;
202227
}
203228

204229
export const useUiStore = createWithEqualityFn<UiState>()((set) => ({
@@ -296,4 +321,12 @@ export const useUiStore = createWithEqualityFn<UiState>()((set) => ({
296321
saveStoredProjectLayouts(projectLayouts);
297322
return { projectLayouts };
298323
}),
324+
hiddenSidebarViews: readStoredHiddenSidebarViews(),
325+
setSidebarViewHidden: (viewId, hidden) =>
326+
set((state) => {
327+
const next = state.hiddenSidebarViews.filter((id) => id !== viewId);
328+
if (hidden) next.push(viewId);
329+
saveStoredHiddenSidebarViews(next);
330+
return { hiddenSidebarViews: next };
331+
}),
299332
}));

docs/release-notes/unreleased.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,4 @@ Changes collected after `v1.2.1` and before the next version tag.
166166
- Desktop: the inline task editor's footer is back to Delete, Save and Cancel. The Duplicate button and the just-added Convert to Reference button are gone; both actions live in the task row's right-click / ⋯ menu, where they already were. (#1108)
167167
- Desktop WebDAV: the request asking servers for uncompressed responses was silently dropped by the HTTP layer, so servers behind a compressing proxy (nginx, Cloudflare) could still answer with weak ETags on desktop. The header now goes through, matching Android. (#1056)
168168
- Turning off Priorities or Time estimates now switches off every remaining place they still acted: Focus no longer offers or applies a Priority sort or grouping, the Weekly Review's estimate look-back shows again for everyone at defaults (it was hidden unless Time estimates had been explicitly switched on), pushed calendar events and the shared .ics feed size by the plain default instead of a hidden estimate, and quick-add help stops listing `/priority:<level>`. Stored preferences and task data are kept and come back when the feature is re-enabled.
169+
- Desktop: sidebar entries can now be hidden individually under Settings → General → Sidebar views (Focus, Someday, Waiting For, Reference, Calendar, Review, Contexts, Board, Done, Archived, Trash). The choice is per device and only affects navigation: hidden views keep their data and stay reachable from search. (#1115)

packages/core/src/i18n/i18n-locales.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export const LOCALES = {
8181
mode: 'overrides',
8282
native: 'Tiếng Việt',
8383
nonLatin: false,
84-
translatedKeyFloor: 2220,
84+
translatedKeyFloor: 2222,
8585
},
8686
zh: {
8787
loadSync: () => require('./locales/zh-Hans') as typeof import('./locales/zh-Hans'),

packages/core/src/i18n/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,6 +1369,8 @@ export const en: Record<string, string> = {
13691369
'settings.featurePomodoroDesc': 'Enable the optional Pomodoro panel in Focus view.',
13701370
'settings.featureTimeline': 'Timeline view',
13711371
'settings.featureTimelineDesc': 'Show a read-only timeline of dated tasks in the sidebar.',
1372+
'settings.sidebarViews': 'Sidebar views',
1373+
'settings.sidebarViewsDesc': 'Choose which views appear in the sidebar. Hidden views stay available from search.',
13721374
'settings.pomodoroCustomPreset': 'Custom preset',
13731375
'settings.pomodoroCustomPresetDesc': 'Add one extra focus/break preset. Matching a built-in preset keeps the built-in chips only.',
13741376
'settings.pomodoroFocusMinutes': 'Focus minutes',

0 commit comments

Comments
 (0)