claudeup 6.2.0 → 6.3.0

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.
Files changed (153) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/go-module.test.ts +43 -0
  31. package/src/__tests__/install-command.test.ts +3 -3
  32. package/src/__tests__/install-plan.test.ts +13 -7
  33. package/src/__tests__/manifest.test.ts +16 -5
  34. package/src/__tests__/markdown-renderer.test.ts +0 -1
  35. package/src/__tests__/marketplace-badge.test.ts +111 -90
  36. package/src/__tests__/marketplace-version.test.ts +64 -61
  37. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  38. package/src/__tests__/plugin-requires.test.ts +55 -1
  39. package/src/__tests__/plugin-setup.test.ts +111 -9
  40. package/src/__tests__/profile-command.test.ts +1 -7
  41. package/src/__tests__/profile-materializer.test.ts +31 -8
  42. package/src/__tests__/profile-sync.test.ts +23 -5
  43. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  44. package/src/__tests__/resolve-executable.test.ts +1 -1
  45. package/src/__tests__/resolver.test.ts +38 -13
  46. package/src/__tests__/scope-action.test.ts +52 -50
  47. package/src/__tests__/scope-squares.test.tsx +4 -5
  48. package/src/__tests__/skill-install-files.test.ts +111 -103
  49. package/src/__tests__/symlink-manager.test.ts +27 -10
  50. package/src/__tests__/theme-env.test.ts +281 -0
  51. package/src/__tests__/theme-resolve.test.ts +380 -0
  52. package/src/__tests__/update-apply.test.ts +1 -3
  53. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  54. package/src/__tests__/version-snapshot.test.ts +183 -165
  55. package/src/cli/ansi.ts +23 -15
  56. package/src/cli/doctor.ts +10 -9
  57. package/src/cli/install.ts +3 -3
  58. package/src/cli/profile.ts +5 -4
  59. package/src/cli/prompt.ts +0 -1
  60. package/src/cli/router.ts +69 -2
  61. package/src/cli/update-view.ts +11 -6
  62. package/src/cli/update.ts +9 -4
  63. package/src/cli/upgrade.ts +6 -4
  64. package/src/data/alias-flags.ts +260 -260
  65. package/src/data/gitignore-defaults.ts +20 -20
  66. package/src/data/gitignore-reasons.ts +86 -92
  67. package/src/data/gitignore-templates.ts +14 -14
  68. package/src/data/predefined-profiles.ts +1 -2
  69. package/src/data/skill-repos.ts +43 -35
  70. package/src/main.tsx +29 -6
  71. package/src/opentui.d.ts +1 -3
  72. package/src/prerunner/index.ts +7 -8
  73. package/src/services/alias-settings.ts +20 -23
  74. package/src/services/alias-shell-writer.ts +800 -803
  75. package/src/services/alias-store.ts +77 -77
  76. package/src/services/claude-settings.ts +27 -18
  77. package/src/services/content-drift.ts +14 -4
  78. package/src/services/conventions-manager.ts +692 -617
  79. package/src/services/doctor.ts +464 -403
  80. package/src/services/gitignore-detector.ts +113 -103
  81. package/src/services/gitignore-fixer.ts +204 -189
  82. package/src/services/gitignore-prerun.ts +26 -26
  83. package/src/services/gitignore-resolver.ts +162 -144
  84. package/src/services/gitignore-service.ts +120 -117
  85. package/src/services/install-plan.ts +4 -4
  86. package/src/services/local-marketplace.ts +7 -5
  87. package/src/services/manifest.ts +1 -1
  88. package/src/services/marketplace-catalog-git.ts +5 -2
  89. package/src/services/marketplace-fetcher.ts +241 -235
  90. package/src/services/plugin-manager.ts +17 -20
  91. package/src/services/plugin-mcp-config.ts +2 -2
  92. package/src/services/plugin-requires.ts +16 -8
  93. package/src/services/plugin-setup.ts +32 -15
  94. package/src/services/plugin-version-check.ts +4 -4
  95. package/src/services/profile-materializer.ts +9 -5
  96. package/src/services/profile-sync.ts +12 -4
  97. package/src/services/profiles.ts +3 -3
  98. package/src/services/resolver.ts +10 -6
  99. package/src/services/settings-manager.ts +82 -46
  100. package/src/services/skills-manager.ts +11 -9
  101. package/src/services/symlink-manager.ts +2 -5
  102. package/src/services/toolchain.ts +3 -3
  103. package/src/services/update-cache.ts +1 -1
  104. package/src/services/update-engine.ts +4 -4
  105. package/src/services/update-plan.ts +9 -11
  106. package/src/services/version-snapshot.ts +88 -88
  107. package/src/types/gitignore.ts +28 -28
  108. package/src/ui/App.tsx +36 -36
  109. package/src/ui/adapters/settingsAdapter.ts +2 -2
  110. package/src/ui/components/CategoryHeader.tsx +1 -1
  111. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  112. package/src/ui/components/ScopeIndicator.tsx +3 -1
  113. package/src/ui/components/ScrollableList.tsx +2 -1
  114. package/src/ui/components/StyledText.tsx +1 -1
  115. package/src/ui/components/layout/FooterHints.tsx +5 -3
  116. package/src/ui/components/layout/Panel.tsx +2 -2
  117. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  118. package/src/ui/components/modals/InputModal.tsx +7 -1
  119. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  120. package/src/ui/components/modals/SelectModal.tsx +1 -1
  121. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  122. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  123. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  124. package/src/ui/hooks/index.ts +0 -1
  125. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  126. package/src/ui/hooks/useMismatchModal.ts +4 -10
  127. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  128. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  129. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  130. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  131. package/src/ui/renderers/profileRenderers.tsx +9 -8
  132. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  133. package/src/ui/renderers/skillRenderers.tsx +14 -14
  134. package/src/ui/screens/AliasScreen.tsx +31 -34
  135. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  136. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  137. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  138. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  139. package/src/ui/screens/McpScreen.tsx +10 -10
  140. package/src/ui/screens/PluginsScreen.tsx +17 -21
  141. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  142. package/src/ui/screens/SkillsScreen.tsx +16 -14
  143. package/src/ui/state/AppContext.tsx +4 -8
  144. package/src/ui/state/DimensionsContext.tsx +3 -2
  145. package/src/ui/theme-mode.ts +20 -38
  146. package/src/ui/theme-resolve.ts +148 -0
  147. package/src/utils/clipboard.ts +5 -5
  148. package/src/utils/command-utils.ts +2 -2
  149. package/src/utils/config-dir.ts +1 -3
  150. package/src/utils/file-locking.ts +88 -86
  151. package/src/utils/go-module.ts +36 -0
  152. package/src/ui/hooks/useAsyncData.ts +0 -127
  153. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -5,10 +5,10 @@
5
5
  * Uses lock files for cross-platform compatibility
6
6
  */
7
7
 
8
- import { open, mkdir } from 'node:fs/promises';
9
- import { dirname } from 'node:path';
10
- import { existsSync } from 'node:fs';
11
- import type { FileHandle } from 'node:fs/promises';
8
+ import { existsSync } from "node:fs";
9
+ import { mkdir, open } from "node:fs/promises";
10
+ import type { FileHandle } from "node:fs/promises";
11
+ import { dirname } from "node:path";
12
12
 
13
13
  /**
14
14
  * Maximum time to wait for a lock (milliseconds)
@@ -30,62 +30,64 @@ const LOCK_RETRY_INTERVAL_MS = 100;
30
30
  * @throws Error if lock cannot be acquired within timeout
31
31
  */
32
32
  export async function withFileLock<T>(
33
- filePath: string,
34
- operation: () => Promise<T>,
33
+ filePath: string,
34
+ operation: () => Promise<T>,
35
35
  ): Promise<T> {
36
- const lockPath = `${filePath}.lock`;
37
- const lockDir = dirname(lockPath);
38
-
39
- // Ensure lock directory exists
40
- if (!existsSync(lockDir)) {
41
- await mkdir(lockDir, { recursive: true });
42
- }
43
-
44
- const startTime = Date.now();
45
- let fileHandle: FileHandle | null = null;
46
-
47
- // Try to acquire lock with retries
48
- while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
49
- try {
50
- // Try to create lock file exclusively (fails if exists)
51
- fileHandle = await open(lockPath, 'wx');
52
-
53
- // Write PID to lock file for debugging
54
- await fileHandle.write(`${process.pid}\n${new Date().toISOString()}\n`);
55
-
56
- break;
57
- } catch (error: unknown) {
58
- // Lock file exists, retry after interval
59
- if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
60
- await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS));
61
- continue;
62
- }
63
- throw error;
64
- }
65
- }
66
-
67
- if (fileHandle === null) {
68
- throw new Error(
69
- `Failed to acquire lock on ${filePath} after ${LOCK_TIMEOUT_MS}ms. ` +
70
- `Another process may be holding the lock. Lock file: ${lockPath}`,
71
- );
72
- }
73
-
74
- try {
75
- // Execute operation with lock held
76
- return await operation();
77
- } finally {
78
- // Release lock
79
- try {
80
- await fileHandle.close();
81
- // Delete lock file
82
- const fs = await import('node:fs/promises');
83
- await fs.unlink(lockPath);
84
- } catch (error) {
85
- // Log error but don't throw - operation succeeded
86
- console.error(`Warning: Failed to release lock ${lockPath}:`, error);
87
- }
88
- }
36
+ const lockPath = `${filePath}.lock`;
37
+ const lockDir = dirname(lockPath);
38
+
39
+ // Ensure lock directory exists
40
+ if (!existsSync(lockDir)) {
41
+ await mkdir(lockDir, { recursive: true });
42
+ }
43
+
44
+ const startTime = Date.now();
45
+ let fileHandle: FileHandle | null = null;
46
+
47
+ // Try to acquire lock with retries
48
+ while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
49
+ try {
50
+ // Try to create lock file exclusively (fails if exists)
51
+ fileHandle = await open(lockPath, "wx");
52
+
53
+ // Write PID to lock file for debugging
54
+ await fileHandle.write(`${process.pid}\n${new Date().toISOString()}\n`);
55
+
56
+ break;
57
+ } catch (error: unknown) {
58
+ // Lock file exists, retry after interval
59
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
60
+ await new Promise((resolve) =>
61
+ setTimeout(resolve, LOCK_RETRY_INTERVAL_MS),
62
+ );
63
+ continue;
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ if (fileHandle === null) {
70
+ throw new Error(
71
+ `Failed to acquire lock on ${filePath} after ${LOCK_TIMEOUT_MS}ms. ` +
72
+ `Another process may be holding the lock. Lock file: ${lockPath}`,
73
+ );
74
+ }
75
+
76
+ try {
77
+ // Execute operation with lock held
78
+ return await operation();
79
+ } finally {
80
+ // Release lock
81
+ try {
82
+ await fileHandle.close();
83
+ // Delete lock file
84
+ const fs = await import("node:fs/promises");
85
+ await fs.unlink(lockPath);
86
+ } catch (error) {
87
+ // Log error but don't throw - operation succeeded
88
+ console.error(`Warning: Failed to release lock ${lockPath}:`, error);
89
+ }
90
+ }
89
91
  }
90
92
 
91
93
  /**
@@ -95,8 +97,8 @@ export async function withFileLock<T>(
95
97
  * @returns True if lock file exists
96
98
  */
97
99
  export function isFileLocked(filePath: string): boolean {
98
- const lockPath = `${filePath}.lock`;
99
- return existsSync(lockPath);
100
+ const lockPath = `${filePath}.lock`;
101
+ return existsSync(lockPath);
100
102
  }
101
103
 
102
104
  /**
@@ -106,16 +108,16 @@ export function isFileLocked(filePath: string): boolean {
106
108
  * @param filePath - Path to file whose lock to remove
107
109
  */
108
110
  export async function forceRemoveLock(filePath: string): Promise<void> {
109
- const lockPath = `${filePath}.lock`;
110
- try {
111
- const fs = await import('node:fs/promises');
112
- await fs.unlink(lockPath);
113
- } catch (error: unknown) {
114
- // Ignore ENOENT (lock doesn't exist)
115
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
116
- throw error;
117
- }
118
- }
111
+ const lockPath = `${filePath}.lock`;
112
+ try {
113
+ const fs = await import("node:fs/promises");
114
+ await fs.unlink(lockPath);
115
+ } catch (error: unknown) {
116
+ // Ignore ENOENT (lock doesn't exist)
117
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
118
+ throw error;
119
+ }
120
+ }
119
121
  }
120
122
 
121
123
  /**
@@ -125,20 +127,20 @@ export async function forceRemoveLock(filePath: string): Promise<void> {
125
127
  * @returns Lock info or null if not locked
126
128
  */
127
129
  export async function getLockInfo(
128
- filePath: string,
130
+ filePath: string,
129
131
  ): Promise<{ pid: string; timestamp: string } | null> {
130
- const lockPath = `${filePath}.lock`;
131
-
132
- if (!existsSync(lockPath)) {
133
- return null;
134
- }
135
-
136
- try {
137
- const fs = await import('node:fs/promises');
138
- const content = await fs.readFile(lockPath, 'utf-8');
139
- const [pid, timestamp] = content.trim().split('\n');
140
- return { pid, timestamp };
141
- } catch (error) {
142
- return null;
143
- }
132
+ const lockPath = `${filePath}.lock`;
133
+
134
+ if (!existsSync(lockPath)) {
135
+ return null;
136
+ }
137
+
138
+ try {
139
+ const fs = await import("node:fs/promises");
140
+ const content = await fs.readFile(lockPath, "utf-8");
141
+ const [pid, timestamp] = content.trim().split("\n");
142
+ return { pid, timestamp };
143
+ } catch (error) {
144
+ return null;
145
+ }
144
146
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Go module path → installed binary name.
3
+ *
4
+ * `go install` names the binary after the last path element of the module
5
+ * path, EXCEPT when that element is a major-version suffix: the module
6
+ * `github.com/MadAppGang/tmux-mcp/v2` installs `tmux-mcp`, not `v2`. Taking a
7
+ * plain basename therefore points every later check at a binary that never
8
+ * exists — presence probes fail, so the module is reinstalled on every run,
9
+ * doctor reports it missing, and the pin comparison never runs at all.
10
+ *
11
+ * Only the Go rule lives here. npm and cargo share the `name@version` syntax
12
+ * but not the suffix convention — an npm scoped package can legitimately be
13
+ * called `@scope/v2` — so this must not be folded into the generic spec parser.
14
+ */
15
+
16
+ /**
17
+ * Trailing major-version suffix per the Go modules spec: `/vN` with N >= 2.
18
+ * Anchored to the END of the path with nothing after `vN`, so a segment that
19
+ * merely starts with "v2" (`github.com/x/v2tool`) is a real name, not a suffix.
20
+ * v0 and v1 are never written as a path suffix. The alternation, not a plain
21
+ * `[2-9][0-9]*`, because that shorthand rejects v10–v19 — a first digit of 1
22
+ * is only excluded when it is the ONLY digit.
23
+ */
24
+ const MAJOR_VERSION_SUFFIX = /\/v(?:[2-9]|[1-9][0-9]+)$/;
25
+
26
+ /**
27
+ * The binary `go install <modulePath>` produces.
28
+ *
29
+ * Takes the MODULE PATH — the part before any `@version` — so callers that
30
+ * hold a full `path@version` spec split it first. An empty or single-segment
31
+ * input is returned unchanged.
32
+ */
33
+ export function goBinaryName(modulePath: string): string {
34
+ const withoutSuffix = modulePath.replace(MAJOR_VERSION_SUFFIX, "");
35
+ return withoutSuffix.split("/").pop() || withoutSuffix;
36
+ }
@@ -1,127 +0,0 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import type { AsyncData } from "../state/types.js";
3
-
4
- interface UseAsyncDataOptions<T> {
5
- /** Function that fetches data */
6
- fetcher: () => Promise<T>;
7
- /** Dependencies that trigger refetch when changed */
8
- deps?: unknown[];
9
- /** Whether to fetch immediately on mount */
10
- immediate?: boolean;
11
- }
12
-
13
- interface UseAsyncDataResult<T> {
14
- /** Current async data state */
15
- data: AsyncData<T>;
16
- /** Whether data is currently loading */
17
- isLoading: boolean;
18
- /** Whether data has been successfully loaded */
19
- isSuccess: boolean;
20
- /** Whether there was an error loading data */
21
- isError: boolean;
22
- /** Error if any */
23
- error: Error | null;
24
- /** Trigger a manual refetch */
25
- refetch: () => Promise<void>;
26
- /** Raw data if available */
27
- value: T | null;
28
- }
29
-
30
- /**
31
- * Hook for fetching async data with loading/error states
32
- */
33
- export function useAsyncData<T>({
34
- fetcher,
35
- deps = [],
36
- immediate = true,
37
- }: UseAsyncDataOptions<T>): UseAsyncDataResult<T> {
38
- const [data, setData] = useState<AsyncData<T>>({ status: "idle" });
39
-
40
- const refetch = useCallback(async () => {
41
- setData({ status: "loading" });
42
- try {
43
- const result = await fetcher();
44
- setData({ status: "success", data: result });
45
- } catch (err) {
46
- setData({
47
- status: "error",
48
- error: err instanceof Error ? err : new Error(String(err)),
49
- });
50
- }
51
- }, [fetcher]);
52
-
53
- useEffect(() => {
54
- if (immediate) {
55
- refetch();
56
- }
57
- // eslint-disable-next-line react-hooks/exhaustive-deps
58
- }, deps);
59
-
60
- return {
61
- data,
62
- isLoading: data.status === "loading",
63
- isSuccess: data.status === "success",
64
- isError: data.status === "error",
65
- error: data.status === "error" ? data.error : null,
66
- refetch,
67
- value: data.status === "success" ? data.data : null,
68
- };
69
- }
70
-
71
- /**
72
- * Hook for fetching async data with debounced query
73
- */
74
- export function useDebouncedAsyncData<T>({
75
- fetcher,
76
- query,
77
- debounceMs = 300,
78
- minQueryLength = 1,
79
- }: {
80
- fetcher: (query: string) => Promise<T>;
81
- query: string;
82
- debounceMs?: number;
83
- minQueryLength?: number;
84
- }): UseAsyncDataResult<T> {
85
- const [data, setData] = useState<AsyncData<T>>({ status: "idle" });
86
-
87
- const refetch = useCallback(async () => {
88
- if (query.length < minQueryLength) {
89
- setData({ status: "idle" });
90
- return;
91
- }
92
-
93
- setData({ status: "loading" });
94
- try {
95
- const result = await fetcher(query);
96
- setData({ status: "success", data: result });
97
- } catch (err) {
98
- setData({
99
- status: "error",
100
- error: err instanceof Error ? err : new Error(String(err)),
101
- });
102
- }
103
- }, [fetcher, query, minQueryLength]);
104
-
105
- useEffect(() => {
106
- if (query.length < minQueryLength) {
107
- setData({ status: "idle" });
108
- return;
109
- }
110
-
111
- const timeoutId = setTimeout(() => {
112
- refetch();
113
- }, debounceMs);
114
-
115
- return () => clearTimeout(timeoutId);
116
- }, [query, debounceMs, minQueryLength, refetch]);
117
-
118
- return {
119
- data,
120
- isLoading: data.status === "loading",
121
- isSuccess: data.status === "success",
122
- isError: data.status === "error",
123
- error: data.status === "error" ? data.error : null,
124
- refetch,
125
- value: data.status === "success" ? data.data : null,
126
- };
127
- }
@@ -1,76 +0,0 @@
1
- import React, {
2
- createContext,
3
- useContext,
4
- useState,
5
- type ReactNode,
6
- } from "react";
7
-
8
- // ============================================================================
9
- // Context Type
10
- // ============================================================================
11
-
12
- interface Timeline {
13
- id: string;
14
- // Timeline instance from useTimeline hook
15
- instance: any;
16
- }
17
-
18
- interface AnimationContextValue {
19
- timelines: Map<string, any>;
20
- registerTimeline: (id: string, timeline: any) => void;
21
- unregisterTimeline: (id: string) => void;
22
- }
23
-
24
- // ============================================================================
25
- // Context
26
- // ============================================================================
27
-
28
- const AnimationContext = createContext<AnimationContextValue | null>(null);
29
-
30
- // ============================================================================
31
- // Provider
32
- // ============================================================================
33
-
34
- interface AnimationProviderProps {
35
- children: ReactNode;
36
- }
37
-
38
- export function AnimationProvider({ children }: AnimationProviderProps) {
39
- const [timelines, setTimelines] = useState<Map<string, any>>(new Map());
40
-
41
- const registerTimeline = (id: string, timeline: any) => {
42
- setTimelines((prev) => {
43
- const next = new Map(prev);
44
- next.set(id, timeline);
45
- return next;
46
- });
47
- };
48
-
49
- const unregisterTimeline = (id: string) => {
50
- setTimelines((prev) => {
51
- const next = new Map(prev);
52
- next.delete(id);
53
- return next;
54
- });
55
- };
56
-
57
- return (
58
- <AnimationContext.Provider
59
- value={{ timelines, registerTimeline, unregisterTimeline }}
60
- >
61
- {children}
62
- </AnimationContext.Provider>
63
- );
64
- }
65
-
66
- // ============================================================================
67
- // Hook: useAnimation
68
- // ============================================================================
69
-
70
- export function useAnimation(): AnimationContextValue {
71
- const context = useContext(AnimationContext);
72
- if (!context) {
73
- throw new Error("useAnimation must be used within an AnimationProvider");
74
- }
75
- return context;
76
- }