-
Notifications
You must be signed in to change notification settings - Fork 181
Add: reactive theme switching (auto dark/light based on OS appearance) #652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
37e91be
Add reactive theme switching (auto dark/light based on OS appearance)
LeonFedotov 1e1c58b
Fix CONFIG_DIR hardcoding and add tw.js cleanup
LeonFedotov 09d9bb4
Split COLORFGBG detection into separate always-applied patch
LeonFedotov 9729e6f
Fix querier arg shift and remove redundant clearCaches
LeonFedotov 4a5eb7d
Address CodeRabbit round 3: sanitize injected strings, tighten marker…
LeonFedotov 70f845d
Merge remote-tracking branch 'upstream/main' into feat/reactive-theme
LeonFedotov 187da52
Strip all comments — repo style, remove misleading version examples
LeonFedotov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { writeReactiveTheme } from './reactiveTheme'; | ||
| import { clearCaches } from './helpers'; | ||
|
|
||
| // Synthetic ThemeProvider snippets derived from real CC minified code. | ||
| // Each version uses different variable names but the same structure. | ||
|
|
||
| // Prefix: module loader + React import for getRequireFuncName / getReactVar to work | ||
| const BUN_PREFIX = | ||
| 'var j=(H,$,A)=>{A=H!=null?H:$};' + | ||
| 'var n7L=($)=>{var W=Symbol.for("react.transitional.element")};' + | ||
| 'var fH=j((AtM,r7L)=>{r7L.exports=n7L()});'; | ||
|
|
||
| // v2.1.89 ThemeProvider (simplified but structurally accurate) | ||
| function buildV289(): string { | ||
| return ( | ||
| BUN_PREFIX + | ||
| 'function rUH(){if(Xv6===void 0)Xv6=uG4()??"dark";return Xv6}' + | ||
| 'function Ad(H){if(H==="auto")return rUH();return H}' + | ||
| 'function uG4(){let H=process.env.COLORFGBG;if(!H)return;' + | ||
| 'let _=H.split(";"),q=_[_.length-1];' + | ||
| 'if(q===void 0||q==="")return;' + | ||
| 'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' + | ||
| 'return K<=6||K===8?"dark":"light"}' + | ||
| 'var Xv6;' + | ||
| 'function pG_({children:H,initialState:_,onThemeSave:q=gG4}){' + | ||
| 'let[K,$]=UG.useState(_??pG4),[O,T]=UG.useState(null),' + | ||
| '[z,A]=UG.useState(()=>(_??K)==="auto"?rUH():"dark"),' + | ||
| 'w=O??K,{internal_querier:f}=W6H();' + | ||
| 'UG.useEffect(()=>{},[w,f]);' + | ||
| 'let Y=w==="auto"?z:w,' + | ||
| 'j=UG.useMemo(()=>({themeSetting:K,' + | ||
| 'setThemeSetting:(D)=>{if($(D),T(null),D==="auto")A(rUH());q?.(D)},' + | ||
| 'setPreviewTheme:(D)=>{if(T(D),D==="auto")A(rUH())},' + | ||
| 'savePreview:()=>{if(O!==null)$(O),T(null),q?.(O)},' + | ||
| 'cancelPreview:()=>{if(O!==null)T(null)},' + | ||
| 'currentTheme:Y}),[K,O,Y,q]);' + | ||
| 'return UG.default.createElement(mG_.Provider,{value:j},H)}' | ||
| ); | ||
| } | ||
|
|
||
| // v2.1.87 ThemeProvider (different variable names) | ||
| function buildV287(): string { | ||
| return ( | ||
| BUN_PREFIX + | ||
| 'function IFH(){if(OR4===void 0)OR4=MR4()??"dark";return OR4}' + | ||
| 'function Ad(H){if(H==="auto")return IFH();return H}' + | ||
| 'function MR4(){let H=process.env.COLORFGBG;if(!H)return;' + | ||
| 'let _=H.split(";"),q=_[_.length-1];' + | ||
| 'if(q===void 0||q==="")return;' + | ||
| 'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' + | ||
| 'return K<=6||K===8?"dark":"light"}' + | ||
| 'var OR4;' + | ||
| 'function E0_({children:H,initialState:_,onThemeSave:q=JR4}){' + | ||
| 'let[$,K]=KG.useState(_??JR4),[O,T]=KG.useState(null),' + | ||
| '[z,A]=KG.useState(()=>(_??$)==="auto"?IFH():"dark"),' + | ||
| 'f=O??$,{internal_querier:w}=E_H();' + | ||
| 'KG.useEffect(()=>{},[f,w]);' + | ||
| 'let Y=f==="auto"?z:f,' + | ||
| 'D=KG.useMemo(()=>({themeSetting:$,' + | ||
| 'setThemeSetting:(j)=>{if(K(j),T(null),j==="auto")A(IFH());q?.(j)},' + | ||
| 'setPreviewTheme:(j)=>{if(T(j),j==="auto")A(IFH())},' + | ||
| 'savePreview:()=>{if(O!==null)K(O),T(null),q?.(O)},' + | ||
| 'cancelPreview:()=>{if(O!==null)T(null)},' + | ||
| 'currentTheme:Y}),[K,O,Y,q]);' + | ||
| 'return KG.default.createElement(E0_.Provider,{value:D},H)}' | ||
| ); | ||
| } | ||
|
|
||
| // v2.1.86 ThemeProvider (single dep in older versions had two deps too) | ||
| function buildV286(): string { | ||
| return ( | ||
| BUN_PREFIX + | ||
| 'function Cd8(){if(qR6===void 0)qR6=_k5()??"dark";return qR6}' + | ||
| 'function Ad(H){if(H==="auto")return Cd8();return H}' + | ||
| 'function _k5(){let H=process.env.COLORFGBG;if(!H)return;' + | ||
| 'let _=H.split(";"),q=_[_.length-1];' + | ||
| 'if(q===void 0||q==="")return;' + | ||
| 'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' + | ||
| 'return K<=6||K===8?"dark":"light"}' + | ||
| 'var qR6;' + | ||
| 'function EGq({children:H,initialState:_,onThemeSave:q=Kk5}){' + | ||
| 'let[O,z]=zA.useState(_??Kk5),[Y,H2]=zA.useState(null),' + | ||
| '[$,w]=zA.useState(()=>(_??O)==="auto"?Cd8():"dark"),' + | ||
| 'j=Y??O,{internal_querier:J}=yq8();' + | ||
| 'zA.useEffect(()=>{},[j,J]);' + | ||
| 'let T=j==="auto"?$:j,' + | ||
| 'X=zA.useMemo(()=>({themeSetting:O,' + | ||
| 'setThemeSetting:(D)=>{if(z(D),H2(null),D==="auto")w(Cd8());q?.(D)},' + | ||
| 'setPreviewTheme:(D)=>{if(H2(D),D==="auto")w(Cd8())},' + | ||
| 'savePreview:()=>{if(Y!==null)z(Y),H2(null),q?.(Y)},' + | ||
| 'cancelPreview:()=>{if(Y!==null)H2(null)},' + | ||
| 'currentTheme:T}),[O,Y,T,q]);' + | ||
| 'return zA.default.createElement(EGq.Provider,{value:X},H)}' | ||
| ); | ||
| } | ||
|
|
||
| const DEFAULT_CONFIG = { darkThemeId: 'dark', lightThemeId: 'light' }; | ||
|
|
||
| describe('reactiveTheme', () => { | ||
| beforeEach(() => { | ||
| clearCaches(); | ||
| }); | ||
|
|
||
| // ==================================================================== | ||
| // useEffect patching | ||
| // ==================================================================== | ||
|
|
||
| it('patches the empty useEffect in v2.1.89 style code', () => { | ||
| const result = writeReactiveTheme(buildV289(), DEFAULT_CONFIG); | ||
|
|
||
| expect(result).not.toBeNull(); | ||
| expect(result).toContain('/tw.js"'); | ||
| expect(result).toContain('(A,f,"dark","light",'); | ||
| expect(result).not.toContain('.useEffect(()=>{}'); | ||
| }); | ||
|
|
||
| it('patches the empty useEffect in v2.1.87 style code', () => { | ||
| const result = writeReactiveTheme(buildV287(), DEFAULT_CONFIG); | ||
|
|
||
| expect(result).not.toBeNull(); | ||
| expect(result).toContain('/tw.js"'); | ||
| expect(result).toContain('(A,w,"dark","light",'); | ||
| expect(result).not.toContain('.useEffect(()=>{}'); | ||
| }); | ||
|
|
||
| it('patches the empty useEffect in v2.1.86 style code', () => { | ||
| const result = writeReactiveTheme(buildV286(), DEFAULT_CONFIG); | ||
|
|
||
| expect(result).not.toBeNull(); | ||
| expect(result).toContain('/tw.js"'); | ||
| expect(result).toContain('(w,J,"dark","light",'); | ||
| expect(result).not.toContain('.useEffect(()=>{}'); | ||
| }); | ||
|
|
||
| it('uses custom theme IDs from config', () => { | ||
| const result = writeReactiveTheme(buildV289(), { | ||
| darkThemeId: 'dark-ansi', | ||
| lightThemeId: 'light-ansi', | ||
| }); | ||
|
|
||
| expect(result).not.toBeNull(); | ||
| expect(result).toContain('"dark-ansi","light-ansi"'); | ||
| }); | ||
|
|
||
| // ==================================================================== | ||
| // Idempotency | ||
| // ==================================================================== | ||
|
|
||
| it('returns content unchanged when already patched', () => { | ||
| const first = writeReactiveTheme(buildV289(), DEFAULT_CONFIG); | ||
| expect(first).not.toBeNull(); | ||
|
|
||
| clearCaches(); | ||
| const second = writeReactiveTheme(first!, DEFAULT_CONFIG); | ||
| expect(second).toBe(first); | ||
| }); | ||
|
|
||
| // ==================================================================== | ||
| // Failure cases | ||
| // ==================================================================== | ||
|
|
||
| it('returns null when ThemeProvider is not found', () => { | ||
| const consoleError = vi | ||
| .spyOn(console, 'error') | ||
| .mockImplementation(() => {}); | ||
|
|
||
| try { | ||
| const result = writeReactiveTheme( | ||
| BUN_PREFIX + 'const x = 1;', | ||
| DEFAULT_CONFIG | ||
| ); | ||
| expect(result).toBeNull(); | ||
| expect(consoleError).toHaveBeenCalledWith( | ||
| 'patch: reactiveTheme: failed to find ThemeProvider function' | ||
| ); | ||
| } finally { | ||
| consoleError.mockRestore(); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { escapeIdent, showDiff } from './index'; | ||
| import { CONFIG_DIR } from '../config'; | ||
|
|
||
| export interface ReactiveThemeConfig { | ||
| darkThemeId: string; | ||
| lightThemeId: string; | ||
| } | ||
|
|
||
| function findThemeProviderRegion(content: string): { | ||
| start: number; | ||
| end: number; | ||
| } | null { | ||
| const marker = /\{themeSetting:[$\w]+,[\s\S]*?currentTheme:[$\w]+\}/; | ||
| const match = content.match(marker); | ||
| if (!match || match.index == null) return null; | ||
|
|
||
| const searchStart = Math.max(0, match.index - 3000); | ||
| const before = content.slice(searchStart, match.index); | ||
| const funcMatch = before.match( | ||
| /function [$\w]+\(\{children:[$\w]+,initialState:[$\w]+[^}]*\}\)\{/g | ||
| ); | ||
| if (!funcMatch) return null; | ||
|
|
||
| const lastFunc = funcMatch[funcMatch.length - 1]; | ||
| const funcStart = before.lastIndexOf(lastFunc); | ||
| if (funcStart === -1) return null; | ||
|
|
||
| const start = searchStart + funcStart; | ||
|
|
||
| const after = content.slice(match.index, match.index + 2000); | ||
| const returnMatch = after.match( | ||
| /return [$\w]+\.default\.createElement\([$\w]+\.Provider,\{value:[$\w]+\},[$\w]+\)\}/ | ||
| ); | ||
| if (!returnMatch || returnMatch.index == null) return null; | ||
|
|
||
| const end = match.index + returnMatch.index + returnMatch[0].length; | ||
| return { start, end }; | ||
| } | ||
|
|
||
| function patchThemeProviderUseEffect( | ||
| content: string, | ||
| config: ReactiveThemeConfig | ||
| ): string | null { | ||
| const region = findThemeProviderRegion(content); | ||
| if (!region) { | ||
| console.error( | ||
| 'patch: reactiveTheme: failed to find ThemeProvider function' | ||
| ); | ||
| return null; | ||
| } | ||
|
|
||
| const regionContent = content.slice(region.start, region.end); | ||
|
|
||
| if (regionContent.includes('/tw.js")')) { | ||
| return content; | ||
| } | ||
|
|
||
| const useEffectPattern = | ||
| /([$\w]+)\.useEffect\(\(\)=>\{\},\[([$\w]+)(?:,([$\w]+))?\]\)/; | ||
| const useEffectMatch = regionContent.match(useEffectPattern); | ||
| if (!useEffectMatch || useEffectMatch.index == null) { | ||
| console.error( | ||
| 'patch: reactiveTheme: failed to find empty useEffect in ThemeProvider' | ||
| ); | ||
| return null; | ||
| } | ||
|
|
||
| const reactVar = useEffectMatch[1]; | ||
| const dep1 = useEffectMatch[2]; | ||
| const dep2 = useEffectMatch[3]; | ||
| const deps = dep2 ? `${dep1},${dep2}` : dep1; | ||
|
|
||
| const setStatePattern = new RegExp( | ||
| `\\[([$\\w]+),([$\\w]+)\\]=${escapeIdent(reactVar)}\\.useState\\(\\(\\)=>\\([^)]+\\)==="?auto"?\\?[$\\w]+\\(\\):"dark"\\)` | ||
| ); | ||
| const setStateMatch = regionContent.match(setStatePattern); | ||
| if (!setStateMatch) { | ||
| console.error( | ||
| 'patch: reactiveTheme: failed to find setState for resolved theme' | ||
| ); | ||
| return null; | ||
| } | ||
|
|
||
| const setStateVar = setStateMatch[2]; | ||
|
|
||
| const querierArg = dep2 ? `,${dep2}` : ',void 0'; | ||
| const twJsPath = CONFIG_DIR + '/tw.js'; | ||
| const replacement = | ||
| `${reactVar}.useEffect(()=>{try{if(${dep1}=="auto"){` + | ||
| `return require(${JSON.stringify(twJsPath)})` + | ||
| `(${setStateVar}${querierArg},${JSON.stringify(config.darkThemeId)},${JSON.stringify(config.lightThemeId)},${JSON.stringify(CONFIG_DIR)})` + | ||
| `}}catch{}},[${deps}])`; | ||
|
|
||
| const absStart = region.start + useEffectMatch.index; | ||
| const absEnd = absStart + useEffectMatch[0].length; | ||
|
|
||
| const newContent = | ||
| content.slice(0, absStart) + replacement + content.slice(absEnd); | ||
|
|
||
| showDiff(content, newContent, replacement, absStart, absEnd); | ||
|
|
||
| return newContent; | ||
| } | ||
|
|
||
| export const writeReactiveTheme = ( | ||
| oldFile: string, | ||
| config: ReactiveThemeConfig | ||
| ): string | null => { | ||
| return patchThemeProviderUseEffect(oldFile, config); | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.