diff --git a/app/containers/appContainer/index.js b/app/containers/appContainer/index.js index 4683fc68..b39359e2 100644 --- a/app/containers/appContainer/index.js +++ b/app/containers/appContainer/index.js @@ -4,12 +4,11 @@ import { connect } from 'react-redux' import electronBridge from '../../utilities/electronBridge' import { updateUpdateAvailableBarStatus } from '../../actions/index' import AboutPage from '../aboutPage' -import Dashboard from '../dashboard' import FindInPage, { isFindInPageAvailable } from '../findInPage' import LoginPage from '../loginPage' import NavigationPanel from '../navigationPanel' import NavigationPanelDetails from '../navigationPanelDetails' -import React, { Component } from 'react' +import React, { Component, lazy, Suspense } from 'react' import SearchPage from '../searchPage' import SnippetPanel from '../snippetPanel' import { Pane, SplitPane } from 'react-split-pane' @@ -20,6 +19,7 @@ import './index.scss' import './scrollbar.scss' const conf = electronBridge.config +const Dashboard = lazy(() => import(/* webpackChunkName: "dashboard" */ '../dashboard')) const themeManager = new ThemeManager() themeManager.setTheme(conf.get('theme')) @@ -129,9 +129,12 @@ export class AppContainer extends Component { } renderDashboard () { - const { updateDashboardStatus } = this.props + const { dashboardModalStatus, updateDashboardStatus } = this.props + if (dashboardModalStatus !== 'ON') return null return ( - + + + ) } diff --git a/app/containers/codeArea/index.js b/app/containers/codeArea/index.js index 35289610..c85d5f7f 100644 --- a/app/containers/codeArea/index.js +++ b/app/containers/codeArea/index.js @@ -1,7 +1,6 @@ import HighlightJS from 'highlight.js' import hljsDefineSolidity from 'highlightjs-solidity' import hljsDefineGraphQL from 'highlightjs-graphql' -import AsciiDoc from '../../utilities/asciidoc' import Markdown from '../../utilities/markdown' import React, { Component } from 'react' import electronBridge from '../../utilities/electronBridge' @@ -63,11 +62,15 @@ resolveHighlightLanguage(hljsDefineGraphQL)(HighlightJS) // register graphql to export default class CodeArea extends Component { constructor (props) { super(props) + this.state = { asciiDoc: null } + this.isMountedForRendering = false this.codeAreaNode = null this.handleMarkdownImageError = this.handleMarkdownImageError.bind(this) } componentDidMount () { + this.isMountedForRendering = true + this.loadAsciiDocIfNeeded() if (this.codeAreaNode) { this.codeAreaNode.addEventListener('error', this.handleMarkdownImageError, true) } @@ -75,15 +78,32 @@ export default class CodeArea extends Component { } componentDidUpdate () { + this.loadAsciiDocIfNeeded() this.initializeMarkdownImageFallbacks() } componentWillUnmount () { + this.isMountedForRendering = false if (this.codeAreaNode) { this.codeAreaNode.removeEventListener('error', this.handleMarkdownImageError, true) } } + loadAsciiDocIfNeeded () { + const { filename, language } = this.props + if (adaptedLanguage(filename, language) !== 'AsciiDoc' || this.asciiDocLoad) return this.asciiDocLoad + + // The converter is large; ordinary snippets should not initialize it. + // Render the current props after loading, so switching snippets cannot + // display an earlier snippet's asynchronously generated HTML. + this.asciiDocLoad = import(/* webpackChunkName: "asciidoc" */ '../../utilities/asciidoc') + .then(module => { + if (this.isMountedForRendering) this.setState({ asciiDoc: module.default }) + }) + .catch(err => logger.error(`Failed to load AsciiDoc renderer with err ${err}`)) + return this.asciiDocLoad + } + initializeMarkdownImageFallbacks () { if (!this.codeAreaNode) return @@ -230,6 +250,7 @@ export default class CodeArea extends Component { } return `
${result.html}
` } catch (err) { + this.renderFailed = true logger.error(`Failed to render Jupyter Notebook content with err ${err}`) return this.createHighlightedCodeBlock(content, language, kTabLength) } @@ -240,9 +261,11 @@ export default class CodeArea extends Component { } createAsciiDocCodeBlock (content, language, kTabLength) { + if (!this.state.asciiDoc) return this.createHighlightedCodeBlock(content, language, kTabLength) try { - return `
${AsciiDoc.render(content)}
` + return `
${this.state.asciiDoc.render(content)}
` } catch (err) { + this.renderFailed = true logger.error(`Failed to render AsciiDoc content with err ${err}`) return this.createHighlightedCodeBlock(content, language, kTabLength) } @@ -277,8 +300,16 @@ export default class CodeArea extends Component { return `
${contentTable}
` } - renderCodeArea (filename, content, lang, kTabLength, siblingFiles) { + getRenderedContent (filename, content, lang, kTabLength, siblingFiles) { + // Keep only this component's most recent result. Callback changes and + // unrelated UI updates must not reparse Markdown, highlight code, or make + // synchronous notebook IPC calls for unchanged content. + const inputs = [filename, content, lang, kTabLength, siblingFiles, this.state.asciiDoc] + if (this.renderedContent && inputs.every((value, index) => value === this.renderedContent.inputs[index])) { + return this.renderedContent.html + } const language = adaptedLanguage(filename, lang) + this.renderFailed = false let htmlContent = '' switch (language) { case 'Jupyter Notebook': @@ -293,6 +324,13 @@ export default class CodeArea extends Component { default: htmlContent = this.createHighlightedCodeBlock(content, language, kTabLength) } + // Preserve retry behavior for transient renderer/IPC failures. + this.renderedContent = this.renderFailed ? null : { inputs, html: htmlContent } + return htmlContent + } + + renderCodeArea (filename, content, lang, kTabLength, siblingFiles) { + const htmlContent = this.getRenderedContent(filename, content, lang, kTabLength, siblingFiles) return (
{ this.codeAreaNode = node } } diff --git a/app/containers/gistEditor/index.js b/app/containers/gistEditor/index.js index f1e37cdf..79239379 100644 --- a/app/containers/gistEditor/index.js +++ b/app/containers/gistEditor/index.js @@ -149,12 +149,14 @@ import './index.scss' import electronBridge from '../../utilities/electronBridge' import { getEditorTheme } from '../../utilities/themeManager' +import { needsBoundedViewport } from './viewport' const { createMarkdownImageReference, getFilesFromTransfer } = require('../../utilities/imageAttachments') const conf = electronBridge.config const highlightTheme = getEditorTheme(conf.get('theme')) +const configuredEditorOptions = conf.get('editor') || {} const defaultOptions = Object.assign({}, { theme: highlightTheme, lineNumbers: true, @@ -164,7 +166,7 @@ const defaultOptions = Object.assign({}, { viewportMargin: Infinity, foldGutter: true, gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] -}, conf.get('editor')) +}, configuredEditorOptions) class CodeMirrorEditor extends Component { constructor (props) { @@ -194,6 +196,8 @@ class CodeMirrorEditor extends Component { this.codeMirror.setOption(key, options[key]) } }) + + if (this.props.className !== prevProps.className) this.codeMirror.refresh() } componentWillUnmount () { @@ -226,7 +230,7 @@ class CodeMirrorEditor extends Component { } render () { - return
+ return
} } @@ -307,13 +311,18 @@ class GistEditor extends Component { render () { const { value, placeholder, readOnly } = this.props + const boundedViewport = needsBoundedViewport(value || '') const options = Object.assign({}, defaultOptions, { placeholder, - readOnly: Boolean(readOnly) + readOnly: Boolean(readOnly), + viewportMargin: boundedViewport && configuredEditorOptions.viewportMargin === undefined + ? 10 + : defaultOptions.viewportMargin }) return ( 20000) return true + + let lines = 1 + for (let index = 0; index < value.length; index++) { + if (value.charCodeAt(index) === 10 && ++lines > 200) return true + } + return false +} diff --git a/app/containers/gistEditorForm/index.js b/app/containers/gistEditorForm/index.js index 8c940ee0..1db9a33b 100644 --- a/app/containers/gistEditorForm/index.js +++ b/app/containers/gistEditorForm/index.js @@ -70,7 +70,10 @@ class GistEditorForm extends Component { }) } - componentDidUpdate () { + componentDidUpdate (prevProps) { + // Local typing does not change initialData. Only compare original content + // when new input arrives, rather than serializing it on every keystroke. + if (prevProps.initialData === this.props.initialData) return const nextInitialValuesKey = getInitialValuesKey(this.props.initialData) if (nextInitialValuesKey !== this.state.initialValuesKey) { diff --git a/app/containers/navigationPanelDetails/index.js b/app/containers/navigationPanelDetails/index.js index cb7d28ea..a59947ec 100644 --- a/app/containers/navigationPanelDetails/index.js +++ b/app/containers/navigationPanelDetails/index.js @@ -4,7 +4,7 @@ import { descriptionParser } from '../../utilities/parser' import electronBridge from '../../utilities/electronBridge' import { getFilteredGistIds } from '../../utilities/gistFilters' import { selectGist, fetchSingleGist, updatescrollRequestStatus } from '../../actions' -import React, { Component } from 'react' +import React, { Component, PureComponent } from 'react' import TagBadges from '../tagBadges' import { t } from '../../utilities/i18n' import { @@ -19,10 +19,35 @@ import './index.scss' const logger = electronBridge.logger const conf = electronBridge.config +class SnippetThumbnail extends PureComponent { + constructor (props) { + super(props) + this.handleClick = () => this.props.onSelect(this.props.entry.id) + this.setNode = node => this.props.onNode(this.props.entry.id, node) + } + + render () { + const { entry, active } = this.props + const className = active + ? (entry.isPublic ? 'active-snippet-thumnail-public' : 'active-snippet-thumnail-private') + : 'snippet-thumnail' + return ( +
  • +
    +
    { entry.title }
    + { entry.tags && } +
    +
  • + ) + } +} + class NavigationPanelDetails extends Component { constructor (props) { super(props) this.snippetNodes = {} + this.handleClicked = this.handleClicked.bind(this) + this.setSnippetNode = this.setSnippetNode.bind(this) } componentDidUpdate () { @@ -58,96 +83,57 @@ class NavigationPanelDetails extends Component { selectGist(gistId) } - decideSnippetListItemClass (gistId) { - if (gistId === this.props.activeGist) { - if (this.props.gists[gistId].brief.public) { - return 'active-snippet-thumnail-public' - } else { - return 'active-snippet-thumnail-private' - } - } - return 'snippet-thumnail' - } - - renderSnippetTags (gistId, customTags) { - if (!shouldShowTagsInSnippetList(conf)) { - return null + getSnippetEntries () { + const { gists, gistTags, activeGistFilters } = this.props + // Settings reads use synchronous IPC. Read each once, not once per row. + const sortingKey = conf.get('snippet:sorting') + const sortingReverse = conf.get('snippet:sortingReverse') + const showTags = shouldShowTagsInSnippetList(conf) + const colored = showTags && shouldColorTags(conf) + const inputs = [gists, gistTags, activeGistFilters, sortingKey, sortingReverse, showTags, colored] + if (this.snippetEntries && inputs.every((value, index) => value === this.snippetEntries.inputs[index])) { + return this.snippetEntries.entries } - const { gistTags } = this.props - const tags = getRegularTagsForGist(gistId, customTags, gistTags) - return ( - - ) + const entries = getFilteredGistIds(gistTags, activeGistFilters) + .map(id => gists[id]) + // Sync can update gists before its tag index. + .filter(Boolean) + .sort(compareGists(sortingKey, sortingReverse)) + .map(gist => { + const firstFilename = Object.keys(gist.brief.files)[0] + const descriptionSource = gist.brief.providerTitle !== undefined + ? gist.brief + : gist.brief.description || firstFilename + const { title, description, customTags } = descriptionParser(descriptionSource) + const id = gist.brief.id + return { + id, + title: title.length > 0 ? title : description, + isPublic: gist.brief.public, + tags: showTags ? getRegularTagsForGist(id, customTags, gistTags) : null, + colored + } + }) + // Retain only the current list. Selection changes do not rebuild metadata. + this.snippetEntries = { inputs, entries } + return entries } renderSnippetThumbnails () { - const { gists, gistTags, activeGistFilters } = this.props - const snippetThumbnails = [] - const filteredGistIds = getFilteredGistIds(gistTags, activeGistFilters) - - if (filteredGistIds.length === 0) { + const entries = this.getSnippetEntries() + if (!entries.length) { return ( -
  • +
  • { t('search.noResults') }
  • ) } - - const rawGists = [] - filteredGistIds.forEach((gistId) => { - // During the synchronization, gists will be updated before the gistTags, - // which introduces an interval where a gist exists in gistTags but not in - // the gists. This guard makes sure we push the gist only when it is already - // available in gists. - if (gists[gistId]) { - // Pick up the content inside the first [] as the snippet thumbnail's title. - // For example, "[Apple is delicious] It's affordable and healthy." will - // pick up "Apple is delicious" as the title. If no brackets are found, - // it shows to the original description. It provides users the flexibility - // to decide what to be shown in the thumbnail. - const gist = gists[gistId] - rawGists.push(gist) - } - }) - - const sortingKey = conf.get('snippet:sorting') - const sortingReverse = conf.get('snippet:sortingReverse') - rawGists.sort(compareGists(sortingKey, sortingReverse)) - - rawGists.forEach((gist) => { - const firstFilename = Object.keys(gist.brief.files)[0] - // '' will be converted to false, so this statement can handle situations - // for null, '' and undefined - const rawDescription = gist.brief.description || firstFilename - - const descriptionSource = gist.brief.providerTitle !== undefined - ? gist.brief - : rawDescription - const { title, description, customTags } = descriptionParser(descriptionSource) - const thumbnailTitle = title.length > 0 ? title : description - const gistId = gist.brief.id - snippetThumbnails.push( -
  • this.setSnippetNode(gistId, node) }> -
    -
    { thumbnailTitle }
    - { this.renderSnippetTags(gistId, customTags) } -
    -
  • - ) - }) - - return snippetThumbnails - } // renderSnippetThumbnails() + return entries.map(entry => ( + + )) + } render () { return ( diff --git a/app/containers/searchPage/index.js b/app/containers/searchPage/index.js index 6cbc8170..f893c91a 100644 --- a/app/containers/searchPage/index.js +++ b/app/containers/searchPage/index.js @@ -127,19 +127,11 @@ class SearchPage extends Component { } updateInputValue (evt) { - this.setState({ - selectedIndex: 0, - inputValue: evt.target.value - }) - } - - queryInputValue (evt) { const inputValue = evt.target.value - - const searchIndex = this.props.searchIndex - const results = searchIndex.fuseSearch(inputValue) this.setState({ - searchResults: results + selectedIndex: 0, + inputValue, + searchResults: this.props.searchIndex.fuseSearch(inputValue) }) } @@ -370,8 +362,7 @@ class SearchPage extends Component { autoFocus value={ this.state.inputValue } onChange={ this.updateInputValue.bind(this) } - onKeyDown={ this.handleKeyDown.bind(this) } - onKeyUp={ this.queryInputValue.bind(this) }/> + onKeyDown={ this.handleKeyDown.bind(this) }/> { this.renderResultCount() }
      diff --git a/app/renderFixtures.js b/app/renderFixtures.js index 555fa6ee..8eee9ee9 100644 --- a/app/renderFixtures.js +++ b/app/renderFixtures.js @@ -299,6 +299,59 @@ function getBaseState () { function getFixtureOverrides (name) { switch (name) { + case 'everyday': + case 'memory-flow': + case 'memory-search': + case 'everyday-search': { + const library = {} + const librarySize = name === 'memory-search' ? 1000 : 100 + const fileLines = name === 'memory-search' ? 100 : 20 + for (let index = 0; index < librarySize; index++) { + const id = `everyday-${index}` + const files = name === 'memory-flow' && index === 1 + ? { 'notes.md': { filename: 'notes.md', language: 'Markdown', content: '# Profile Markdown\n\n**Ready to read**' } } + : { 'helper.js': { filename: 'helper.js', language: 'JavaScript', content: Array.from({ length: fileLines }, (_, line) => `const value${line} = ${index + line};`).join('\n') } } + library[id] = createGist(id, `[Utility ${String(index).padStart(3, '0')}] #common Everyday helper`, false, files, '2026-06-29T16:00:00Z') + } + const ids = Object.keys(library) + return { + activeGist: ids[0], + gists: library, + gistTags: { [Prefixed('All')]: ids, [Prefixed('JavaScript')]: ids, common: ids }, + pinnedTags: [], + searchWindowStatus: ['everyday-search', 'memory-search'].includes(name) ? 'ON' : 'OFF', + initialSearchQuery: name === 'memory-search' ? 'const' : name === 'everyday-search' ? 'utility' : '' + } + } + case 'large-collapsed': + case 'large-editor': + return { + gistEditModalStatus: name === 'large-editor' ? 'ON' : 'OFF', + fileExpandStatus: { 'fixture-gist-1-large.js': false }, + gists: { + ...gists, + 'fixture-gist-1': createGist('fixture-gist-1', '[Large snippet fixture] #smoke', false, { + 'large.js': { + filename: 'large.js', + language: 'JavaScript', + content: Array.from({ length: 10000 }, (_, i) => `const item${i} = "example ${i}";`).join('\n') + } + }, '2026-06-29T16:00:00Z') + } + } + case 'asciidoc': + return { + gists: { + ...gists, + 'fixture-gist-1': createGist('fixture-gist-1', '[AsciiDoc fixture] #smoke', false, { + 'example.adoc': { + filename: 'example.adoc', + language: 'AsciiDoc', + content: '= On-demand AsciiDoc\n\n*Formatted after loading*\n\nA lightweight startup.' + } + }, '2026-06-29T16:00:00Z') + } + } case 'about': return { aboutModalStatus: 'ON' } case 'dashboard': @@ -449,7 +502,9 @@ export function getRenderFixture (name) { } : null, name, - searchIndexRecords, + searchIndexRecords: stateOverrides.gists + ? Object.values(stateOverrides.gists).map(gist => SearchIndex.buildSearchRecord(gist)) + : searchIndexRecords, state: Object.assign({}, getBaseState(), stateOverrides) } } diff --git a/app/utilities/search/records.js b/app/utilities/search/records.js index 6ea41b89..d75dd90d 100644 --- a/app/utilities/search/records.js +++ b/app/utilities/search/records.js @@ -206,6 +206,7 @@ function buildSearchMatch (record, match) { const key = match.key const matchedFile = getMatchedFile(record, match) const isContentMatch = key === 'files.content' + const excerpt = isContentMatch ? createHighlightedExcerpt(match.value, match.indices) : null const baseLabel = MATCH_KEY_LABELS[key] || key const fileLabel = matchedFile && matchedFile.filename ? `${baseLabel} in ${matchedFile.filename}` @@ -216,10 +217,10 @@ function buildSearchMatch (record, match) { label: fileLabel, filename: matchedFile ? matchedFile.filename : null, language: matchedFile ? matchedFile.language : null, - segments: createHighlightedSegments(match.value, match.indices), - excerpt: isContentMatch - ? createHighlightedExcerpt(match.value, match.indices) - : null + // The UI shows only an excerpt for content hits. Do not retain highlighted + // copies of every line in every matching file while search is open. + segments: excerpt ? excerpt.segments : createHighlightedSegments(match.value, match.indices), + excerpt } } diff --git a/configs/loaders/woff2Only.js b/configs/loaders/woff2Only.js new file mode 100644 index 00000000..908edba6 --- /dev/null +++ b/configs/loaders/woff2Only.js @@ -0,0 +1,18 @@ +// Electron supports WOFF2. Keep fallback formats only for font faces that do +// not provide WOFF2, rather than bundling multiple copies of the same font. +module.exports = function woff2Only (source) { + return source.replace(/@font-face\s*\{[^}]*\}/gi, face => { + const sources = [...face.matchAll(/\bsrc\s*:\s*([^;}]+)(?:;|(?=\}))/gi)] + const woff2 = sources.flatMap(match => match[1].split(',')) + .find(value => /format\(\s*(['"]?)woff2\1\s*\)/i.test(value)) + + if (!woff2) return face + + let replaced = false + return face.replace(/\bsrc\s*:\s*[^;}]+(?:;|(?=\}))/gi, () => { + if (replaced) return '' + replaced = true + return `src: ${woff2.trim()};` + }) + }) +} diff --git a/docs/screenshots/performance-everyday.png b/docs/screenshots/performance-everyday.png new file mode 100644 index 00000000..f47bbbd4 Binary files /dev/null and b/docs/screenshots/performance-everyday.png differ diff --git a/electron-builder.js b/electron-builder.js index ef18a92e..759f7896 100644 --- a/electron-builder.js +++ b/electron-builder.js @@ -114,6 +114,24 @@ const topLevelRendererOnlyNodeModules = [ 'entities' ] +// These packages also ship browser builds, coverage output, or duplicate font +// and grammar assets. Main-process code uses their normal runtime entry points; +// renderer dependencies have already been compiled by webpack. Keep original +// Prism grammars for the notebook renderer's dynamic language loading. +const redundantRuntimeAssets = [ + 'bluebird/js/browser/**', + 'dompurify/src/**', + 'dompurify/dist/purify.cov.cjs.js', + 'dompurify/dist/purify.js', + 'dompurify/dist/purify.min.js', + 'prismjs/components/*.min.js', + 'prismjs/plugins/**', + 'prismjs/themes/**', + 'winston/dist/**', + '@asamuzakjp/css-color/src/**', + '@asamuzakjp/css-color/dist/browser/**' +] + // Apply package exclusions to both direct dependencies and nested copies so // transitive renderer-only packages do not remain in the final archive. function excludeNodeModule (packageName) { @@ -145,6 +163,10 @@ module.exports = { '!node_modules/@types/**', '!node_modules/**/node_modules/@types/**', ...rendererOnlyNodeModules.flatMap(excludeNodeModule), + ...redundantRuntimeAssets.flatMap(pattern => [ + `!node_modules/${pattern}`, + `!node_modules/**/node_modules/${pattern}` + ]), ...topLevelRendererOnlyNodeModules.map(packageName => `!node_modules/${packageName}/**`) ], appId: 'com.cosmox.lepton', diff --git a/index.html b/index.html index 89a394ee..56f0f1e1 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,6 @@ Lepton - -
      diff --git a/tests/configs/electronBuilder.test.js b/tests/configs/electronBuilder.test.js index e789196d..d2fca007 100644 --- a/tests/configs/electronBuilder.test.js +++ b/tests/configs/electronBuilder.test.js @@ -5,6 +5,19 @@ const require = createRequire(import.meta.url) const builderConfig = require('../../electron-builder') describe('electron-builder distribution config', () => { + it('excludes duplicate runtime builds while retaining notebook language sources', () => { + expect(builderConfig.files).toEqual(expect.arrayContaining([ + '!node_modules/prismjs/components/*.min.js', + '!node_modules/**/node_modules/prismjs/components/*.min.js', + '!node_modules/dompurify/dist/purify.cov.cjs.js', + '!node_modules/bluebird/js/browser/**', + '!node_modules/winston/dist/**' + ])) + expect(builderConfig.files).not.toContain('!node_modules/prismjs/**') + expect(builderConfig.files).not.toContain('!node_modules/prismjs/components/**') + expect(builderConfig.files).not.toContain('!node_modules/dompurify/dist/**') + }) + it('names release artifacts with platform and architecture', () => { expect(builderConfig.artifactName).toBe('${productName}-${version}-${os}-${arch}.${ext}') }) diff --git a/tests/configs/woff2Only.test.js b/tests/configs/woff2Only.test.js new file mode 100644 index 00000000..8a64cd1c --- /dev/null +++ b/tests/configs/woff2Only.test.js @@ -0,0 +1,37 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const woff2Only = require('../../configs/loaders/woff2Only') + +describe('Electron font assets', () => { + it('handles unquoted format names emitted by production Sass', () => { + expect(woff2Only('@font-face{src:url(math.woff2) format(woff2),url(math.ttf) format(truetype)}')) + .toBe('@font-face{src: url(math.woff2) format(woff2);}') + }) + + it('keeps WOFF2 and removes legacy sources without changing font descriptors', () => { + const css = `@font-face { + font-family: Icons; + src: url('icons.eot'); + src: url('icons.eot?#iefix') format('embedded-opentype'), url('icons.woff2') format('woff2'), url('icons.ttf') format('truetype'); + font-weight: normal; + }` + const result = woff2Only(css) + expect(result).toContain("src: url('icons.woff2') format('woff2');") + expect(result).not.toMatch(/\.eot|\.ttf/) + expect(result).toContain('font-family: Icons;') + expect(result).toContain('font-weight: normal;') + }) + + it('preserves other faces and non-font URLs', () => { + const fallback = '@font-face { font-family: Other; src: url(other.woff) format("woff"); }' + const image = '.logo { background: url(logo.png); }' + const modern = '@font-face{font-family:Math;src:url(math.woff2) format("woff2"),url(math.woff) format("woff");font-style:italic;}' + const result = woff2Only(fallback + image + modern) + expect(result).toContain(fallback + image) + expect(result).toContain('src: url(math.woff2) format("woff2");') + expect(result).not.toContain('url(math.woff)') + expect(result).toContain('font-style:italic;') + }) +}) diff --git a/tests/containers/codeAreaLazyRenderer.test.js b/tests/containers/codeAreaLazyRenderer.test.js new file mode 100644 index 00000000..910d4cf2 --- /dev/null +++ b/tests/containers/codeAreaLazyRenderer.test.js @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { createRequire } from 'node:module' + +const mocks = vi.hoisted(() => ({ notebookRender: vi.fn() })) + +vi.mock('../../app/utilities/electronBridge', () => ({ + default: { + config: { get: () => 'light' }, + logger: { error: vi.fn() }, + notebook: { render: mocks.notebookRender } + } +})) +vi.mock('../../app/utilities/themeManager', () => ({ getHighlightTheme: () => 'github' })) +vi.mock('highlightjs-solidity', () => ({ default: () => {} })) +vi.mock('highlightjs-graphql', () => ({ default: () => {} })) +vi.mock('../../app/utilities/markdown', () => ({ default: { render: text => text } })) +vi.mock('../../app/utilities/asciidoc', () => ({ + default: { render: text => `

      ${text}

      ` } +})) + +// CodeArea selects a theme through CommonJS; Node needs a CSS stub for that +// import. Electron smoke tests verify the real stylesheet and rendering. +const require = createRequire(import.meta.url) +const cssLoader = require.extensions['.css'] +require.extensions['.css'] = () => {} +let CodeArea +try { + CodeArea = (await import('../../app/containers/codeArea')).default +} finally { + if (cssLoader) require.extensions['.css'] = cssLoader + else delete require.extensions['.css'] +} + +function createCodeArea (language) { + const component = new CodeArea({ filename: 'example', language, content: 'original' }) + component.setState = vi.fn(update => { Object.assign(component.state, update) }) + return component +} + +describe('on-demand AsciiDoc rendering', () => { + it('does not load the converter for ordinary snippets, then loads on a format change', async () => { + const component = createCodeArea('JavaScript') + component.componentDidMount() + expect(component.asciiDocLoad).toBeUndefined() + component.props = { ...component.props, language: 'AsciiDoc' } + component.componentDidUpdate() + await component.asciiDocLoad + expect(component.createAsciiDocCodeBlock('latest', 'AsciiDoc', 4)).toContain('

      latest

      ') + expect(component.setState).toHaveBeenCalledTimes(1) + component.componentDidUpdate() + expect(component.setState).toHaveBeenCalledTimes(1) + }) + + it('keeps readable source while loading and does not update an unmounted component', async () => { + const component = createCodeArea('AsciiDoc') + expect(component.createAsciiDocCodeBlock('plain source', 'AsciiDoc', 4)).toContain('plain source') + component.componentDidMount() + component.componentWillUnmount() + await component.asciiDocLoad + expect(component.setState).not.toHaveBeenCalled() + }) +}) + +describe('snippet render caching', () => { + it('retries notebook rendering after an IPC failure instead of caching the fallback', () => { + mocks.notebookRender.mockReturnValueOnce({ status: false, error: 'Unavailable' }) + .mockReturnValue({ status: true, html: '

      Recovered notebook

      ' }) + const component = createCodeArea('Jupyter Notebook') + const render = () => component.getRenderedContent('example.ipynb', '{}', 'Jupyter Notebook', 4) + expect(render()).not.toContain('Recovered notebook') + expect(render()).toContain('Recovered notebook') + expect(render()).toContain('Recovered notebook') + expect(mocks.notebookRender).toHaveBeenCalledTimes(2) + }) + + it('reuses unchanged code and invalidates when content or rendering inputs change', () => { + const component = createCodeArea('JavaScript') + const highlight = vi.spyOn(component, 'createHighlightedCodeBlock') + const files = {} + const first = component.getRenderedContent('example.js', 'const a = 1', 'JavaScript', 4, files) + expect(component.getRenderedContent('example.js', 'const a = 1', 'JavaScript', 4, files)).toBe(first) + expect(highlight).toHaveBeenCalledTimes(1) + component.getRenderedContent('example.js', 'const a = 2', 'JavaScript', 4, files) + component.getRenderedContent('example.js', 'const a = 2', 'JavaScript', 2, files) + component.getRenderedContent('example.py', 'const a = 2', 'Python', 2, files) + expect(highlight).toHaveBeenCalledTimes(4) + }) + + it('invalidates Markdown when sibling attachments change', () => { + const component = createCodeArea('Markdown') + const markdown = vi.spyOn(component, 'createMarkdownCodeBlock') + const files = {} + component.getRenderedContent('notes.md', '![image](image.png)', 'Markdown', 4, files) + component.getRenderedContent('notes.md', '![image](image.png)', 'Markdown', 4, files) + expect(markdown).toHaveBeenCalledTimes(1) + component.getRenderedContent('notes.md', '![image](image.png)', 'Markdown', 4, { 'image.png': {} }) + expect(markdown).toHaveBeenCalledTimes(2) + }) + + it('replaces cached source after the AsciiDoc converter loads', async () => { + const component = createCodeArea('AsciiDoc') + const initial = component.getRenderedContent('example.adoc', 'Example', 'AsciiDoc', 4) + expect(initial).not.toContain('asciidoc-section') + component.componentDidMount() + await component.asciiDocLoad + expect(component.getRenderedContent('example.adoc', 'Example', 'AsciiDoc', 4)).toContain('

      Example

      ') + }) + + it('avoids repeated notebook IPC while keeping only the latest result', () => { + const component = createCodeArea('Jupyter Notebook') + const notebook = vi.spyOn(component, 'createJupyterNotebookCodeBlock').mockImplementation(content => content) + component.getRenderedContent('example.ipynb', 'first', 'Jupyter Notebook', 4) + component.getRenderedContent('example.ipynb', 'first', 'Jupyter Notebook', 4) + expect(notebook).toHaveBeenCalledTimes(1) + component.getRenderedContent('example.ipynb', 'second', 'Jupyter Notebook', 4) + component.getRenderedContent('example.ipynb', 'first', 'Jupyter Notebook', 4) + expect(notebook).toHaveBeenCalledTimes(3) + }) +}) diff --git a/tests/containers/compatPanel.test.js b/tests/containers/compatPanel.test.js index a7383bff..a9269518 100644 --- a/tests/containers/compatPanel.test.js +++ b/tests/containers/compatPanel.test.js @@ -67,4 +67,20 @@ describe('React 19 panel compatibility', () => { expect(container.querySelector('.collapse').className).toContain('file-collapse') expect(container.textContent).toContain('Expanded file') }) + + it('retains the same mounted content while collapsed and expanded', () => { + const render = expanded => act(() => { + root.render(h(Collapse, { in: expanded }, h('pre', null, 'Keep mounted'))) + }) + render(false) + const content = container.querySelector('pre') + expect(content.textContent).toBe('Keep mounted') + expect(container.querySelector('.collapse').classList.contains('in')).toBe(false) + render(true) + expect(container.querySelector('pre')).toBe(content) + expect(container.querySelector('.collapse').classList.contains('in')).toBe(true) + render(false) + expect(container.querySelector('pre')).toBe(content) + expect(container.querySelector('.collapse').classList.contains('in')).toBe(false) + }) }) diff --git a/tests/containers/criticalFlow.test.js b/tests/containers/criticalFlow.test.js new file mode 100644 index 00000000..d93974dd --- /dev/null +++ b/tests/containers/criticalFlow.test.js @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ settings: {}, get: vi.fn() })) +vi.mock('../../app/utilities/electronBridge', () => ({ + default: { + config: { get: mocks.get }, + logger: { info: vi.fn(), error: vi.fn() }, + ipc: {}, + clipboard: {} + } +})) +vi.mock('../../app/utilities/snippetApi', () => ({ + getSnippetProvider: () => ({ type: 'github' }) +})) +vi.mock('../../app/containers/gistEditor', () => ({ default: () => null })) + +import ConnectedList from '../../app/containers/navigationPanelDetails' +import ConnectedSearch from '../../app/containers/searchPage' +import GistEditorForm from '../../app/containers/gistEditorForm' +import { createDefaultGistFilters } from '../../app/utilities/gistFilters' + +const List = ConnectedList.WrappedComponent +const Search = ConnectedSearch.WrappedComponent + +function synchronousState (component) { + component.setState = vi.fn((update, callback) => { + Object.assign(component.state, typeof update === 'function' ? update(component.state) : update) + if (callback) callback() + }) + return component +} + +beforeEach(() => { + mocks.settings = { 'snippet:sorting': 'description', 'snippet:sortingReverse': false } + mocks.get.mockImplementation(key => mocks.settings[key]) +}) + +describe('everyday snippet navigation', () => { + function makeList () { + const ids = Array.from({ length: 100 }, (_, index) => String(index)) + const gists = Object.fromEntries(ids.map(id => [id, { + brief: { id, description: `[Title ${id.padStart(3, '0')}] #common`, public: false, files: { 'helper.js': {} } } + }])) + return new List({ gists, gistTags: { 'lang@All': ids, common: ids }, activeGistFilters: createDefaultGistFilters(), activeGist: '0' }) + } + + it('reads settings per list rather than per row and reuses metadata on selection', () => { + const list = makeList() + const entries = list.getSnippetEntries() + expect(entries).toHaveLength(100) + expect(mocks.get).toHaveBeenCalledTimes(3) + list.props = { ...list.props, activeGist: '1' } + expect(list.getSnippetEntries()).toBe(entries) + const rows = list.renderSnippetThumbnails() + expect(rows[0].props.active).toBe(false) + expect(rows[1].props.active).toBe(true) + }) + + it('refreshes titles, sort preferences, and tags when their inputs change', () => { + const list = makeList() + const initial = list.getSnippetEntries() + mocks.settings['snippet:sortingReverse'] = true + expect(list.getSnippetEntries()[0].id).toBe('99') + mocks.settings['tag:showInSnippetList'] = true + mocks.settings['tag:colored'] = true + expect(list.getSnippetEntries()[0].tags).toContain('common') + list.props = { ...list.props, gists: { ...list.props.gists, 99: { brief: { ...list.props.gists[99].brief, description: '[Zebra]' } } } } + expect(list.getSnippetEntries()[0].title).toBe('Zebra') + expect(list.getSnippetEntries()).not.toBe(initial) + }) + + it('handles tag entries whose snippets have not arrived during sync', () => { + const list = makeList() + list.props = { ...list.props, gists: {} } + expect(list.getSnippetEntries()).toEqual([]) + }) +}) + +describe('search interaction', () => { + it('searches once per changed input, including paste, and not on arrow navigation', () => { + const fuseSearch = vi.fn(() => [{ id: 'a' }, { id: 'b' }]) + const search = synchronousState(new Search({ searchIndex: { fuseSearch } })) + search.updateInputValue({ target: { value: 'pasted query' } }) + expect(fuseSearch).toHaveBeenCalledExactlyOnceWith('pasted query') + expect(search.state.inputValue).toBe('pasted query') + search.handleKeyDown({ keyCode: 40, preventDefault: vi.fn() }) + expect(search.state.selectedIndex).toBe(1) + expect(fuseSearch).toHaveBeenCalledTimes(1) + search.updateInputValue({ target: { value: '' } }) + expect(fuseSearch).toHaveBeenCalledTimes(2) + expect(search.state.selectedIndex).toBe(0) + }) +}) + +describe('editor input reconciliation', () => { + it('skips initial-content serialization during typing but still accepts new initial data', () => { + const initialData = { get description () { return 'Original' }, gists: [{ filename: 'a.js', content: 'original' }] } + const form = synchronousState(new GistEditorForm({ initialData })) + const read = vi.spyOn(initialData, 'description', 'get') + form.componentDidUpdate({ initialData }) + expect(read).not.toHaveBeenCalled() + form.props = { initialData: { description: 'Replacement', gists: [{ filename: 'b.js', content: 'replacement' }] } } + form.componentDidUpdate({ initialData }) + expect(form.state.values.description).toBe('Replacement') + expect(form.state.values.gistFiles[0].content).toBe('replacement') + }) +}) diff --git a/tests/containers/editorViewport.test.js b/tests/containers/editorViewport.test.js new file mode 100644 index 00000000..50c03829 --- /dev/null +++ b/tests/containers/editorViewport.test.js @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { needsBoundedViewport } from '../../app/containers/gistEditor/viewport' + +describe('editor viewport sizing', () => { + it('keeps ordinary snippets at their natural height', () => { + expect(needsBoundedViewport()).toBe(false) + expect(needsBoundedViewport('const value = 42')).toBe(false) + expect(needsBoundedViewport('x\n'.repeat(199))).toBe(false) + expect(needsBoundedViewport('x'.repeat(20000))).toBe(false) + }) + + it('bounds both long documents and very long wrapped lines', () => { + expect(needsBoundedViewport('x\n'.repeat(200))).toBe(true) + expect(needsBoundedViewport('x\r\n'.repeat(200))).toBe(true) + expect(needsBoundedViewport('x'.repeat(20001))).toBe(true) + }) +}) diff --git a/tests/performance/README.md b/tests/performance/README.md new file mode 100644 index 00000000..72f3bc9c --- /dev/null +++ b/tests/performance/README.md @@ -0,0 +1,26 @@ +# Local performance profiles + +Build the production renderer, then run the profiles on the same machine and +Electron version before and after a change: + +```sh +npm run webpack-prod +node node_modules/electron/cli.js tests/performance/electron-flow.cjs +node node_modules/electron/cli.js tests/performance/electron-search.cjs +``` + +Each run uses isolated configuration and user data. Repeat runs and compare +medians; do not run competing builds during measurement. Set +`LEPTON_PROFILE_REPO` to the path of another checkout with its own production +bundle and the same `memory-flow` and `memory-search` fixtures to compare it +with the same harness. + +The flow profile uses a 100-snippet fixture and measures the first Markdown +render, editor open, Vue mode, typing, and process working sets before and +after editing. The search profile uses 1,000 snippets of 100 lines each and +measures result rendering, a two-token search, heap, DOM nodes, and process +working sets. Working sets include shared pages, so their sum is an upper-bound +indicator rather than the app's unique physical memory. On macOS, the profiles +also report the system `footprint` summary when available; it includes GPU +allocations and may be unavailable without permission. These fixtures do not +measure authentication or network fetches. diff --git a/tests/performance/electron-flow.cjs b/tests/performance/electron-flow.cjs new file mode 100644 index 00000000..0b1d4105 --- /dev/null +++ b/tests/performance/electron-flow.cjs @@ -0,0 +1,128 @@ +// Run after npm run webpack-prod: node node_modules/electron/cli.js tests/performance/electron-flow.cjs +const fs = require('fs') +const os = require('os') +const path = require('path') +const { app, BrowserWindow } = require('electron') +const physicalFootprint = require('./physicalFootprint.cjs') + +const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lepton-performance-')) +process.env.XDG_CONFIG_HOME = profileDir +process.env.LEPTON_RENDER_FIXTURE = 'memory-flow' +fs.writeFileSync(path.join(profileDir, '.leptonrc'), JSON.stringify({ autoUpdate: false, logger: { level: 'error' } })) +app.setPath('userData', path.join(profileDir, 'user-data')) +process.on('exit', () => { + try { fs.rmSync(profileDir, { recursive: true, force: true }) } catch (_) {} +}) + +const repo = path.resolve(process.env.LEPTON_PROFILE_REPO || path.join(__dirname, '../..')) +const started = performance.now() +app.getAppPath = () => repo +require(path.join(repo, 'main.js')) +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +app.whenReady().then(async () => { + try { + while (!BrowserWindow.getAllWindows().length) await wait(10) + const window = BrowserWindow.getAllWindows()[0] + if (window.webContents.isLoading()) { + await new Promise(resolve => window.webContents.once('did-finish-load', resolve)) + } + const run = expression => window.webContents.executeJavaScript(expression, true) + await run(`(async () => { + const deadline = Date.now() + 30000; + const isReady = () => document.querySelectorAll('.snippet-thumnail-list-item').length === 100 && document.querySelector('.code-table'); + while (!isReady() && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + if (!isReady()) throw Error('Browsing fixture did not render'); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + })()`) + const startupMs = performance.now() - started + window.webContents.debugger.attach('1.3') + await window.webContents.debugger.sendCommand('Performance.enable') + const memory = async () => { + await window.webContents.debugger.sendCommand('HeapProfiler.collectGarbage') + await wait(150) + const metrics = app.getAppMetrics() + const processes = metrics.map(metric => ({ + type: metric.type, + serviceName: metric.serviceName, + workingSetKiB: metric.memory.workingSetSize + })) + // Working sets include shared pages; their sum is not unique physical memory. + return { + processes, + physicalFootprintBytes: physicalFootprint(metrics), + sumWorkingSetKiB: processes.reduce((total, metric) => total + metric.workingSetKiB, 0) + } + } + const initialMemory = await memory() + const firstUse = await run(`(async () => { + const poll = async (condition, message) => { + const deadline = performance.now() + 10000; + while (!condition() && performance.now() < deadline) await new Promise(resolve => setTimeout(resolve, 1)); + if (!condition()) throw Error(message); + }; + const click = index => document.querySelectorAll('.snippet-thumnail-list-item > div')[index].click(); + const start = performance.now(); + click(1); + await poll(() => document.querySelector('.markdown-section h1')?.textContent === 'Profile Markdown', 'Markdown did not render'); + const markdownMs = performance.now() - start; + click(0); + await poll(() => document.querySelector('.code-table')?.textContent.includes('const value0 = 0;'), 'Snippet did not render'); + const editStart = performance.now(); + document.querySelector('.snippet-control[title="Edit"]').click(); + await poll(() => document.querySelector('.edit-modal .CodeMirror'), 'Editor did not render'); + const cm = document.querySelector('.edit-modal .CodeMirror').CodeMirror; + await poll(() => cm.getMode().name === 'javascript', 'JavaScript mode did not load'); + const editorMs = performance.now() - editStart; + const input = document.querySelector('.gist-editor-filename-area'); + const modeStart = performance.now(); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'component.vue'); + input.dispatchEvent(new Event('input', { bubbles: true })); + await poll(() => cm.getMode().name === 'vue', 'Vue mode did not load'); + const vueMs = performance.now() - modeStart; + const typing = []; + for (let index = 0; index < 12; index++) { + const typingStart = performance.now(); + cm.replaceRange('a', cm.getCursor()); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + typing.push(performance.now() - typingStart); + } + return { markdownMs, editorMs, vueMs, typing }; + })()`) + const afterMemory = await memory() + await run("document.querySelector('.edit-modal .close').click()") + await wait(200) + const closedMemory = await memory() + const startMetrics = await window.webContents.debugger.sendCommand('Performance.getMetrics') + const switching = await run(`(async () => { + const times = []; + for (let index = 2; index < 14; index++) { + const start = performance.now(); + document.querySelectorAll('.snippet-thumnail-list-item > div')[index].click(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + if (!document.querySelector('.code-table').textContent.includes('const value0 = ' + index + ';')) { + throw Error('Wrong snippet after selection'); + } + times.push(performance.now() - start); + } + return times; + })()`) + const endMetrics = await window.webContents.debugger.sendCommand('Performance.getMetrics') + const metricDelta = name => { + const before = startMetrics.metrics.find(metric => metric.name === name).value + const after = endMetrics.metrics.find(metric => metric.name === name).value + return (after - before) * 1000 + } + console.log('PROFILE=' + JSON.stringify({ + startupMs, initialMemory, firstUse, afterMemory, closedMemory, switching, + switchScriptMs: metricDelta('ScriptDuration'), + switchTaskMs: metricDelta('TaskDuration') + })) + app.exit(0) + } catch (error) { + console.error(error) + app.exit(1) + } +}) diff --git a/tests/performance/electron-search.cjs b/tests/performance/electron-search.cjs new file mode 100644 index 00000000..3bdbdc00 --- /dev/null +++ b/tests/performance/electron-search.cjs @@ -0,0 +1,88 @@ +// Run after npm run webpack-prod: node node_modules/electron/cli.js tests/performance/electron-search.cjs +const fs = require('fs') +const os = require('os') +const path = require('path') +const { app, BrowserWindow } = require('electron') +const physicalFootprint = require('./physicalFootprint.cjs') + +const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lepton-performance-')) +process.env.XDG_CONFIG_HOME = profileDir +process.env.LEPTON_RENDER_FIXTURE = 'memory-search' +fs.writeFileSync(path.join(profileDir, '.leptonrc'), JSON.stringify({ autoUpdate: false, logger: { level: 'error' } })) +app.setPath('userData', path.join(profileDir, 'user-data')) +process.on('exit', () => { + try { fs.rmSync(profileDir, { recursive: true, force: true }) } catch (_) {} +}) + +const repo = path.resolve(process.env.LEPTON_PROFILE_REPO || path.join(__dirname, '../..')) +const started = performance.now() +app.getAppPath = () => repo +require(path.join(repo, 'main.js')) +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +app.whenReady().then(async () => { + try { + while (!BrowserWindow.getAllWindows().length) await wait(10) + const window = BrowserWindow.getAllWindows()[0] + if (window.webContents.isLoading()) { + await new Promise(resolve => window.webContents.once('did-finish-load', resolve)) + } + const ui = await window.webContents.executeJavaScript(`(async () => { + const deadline = Date.now() + 30000; + while (document.querySelectorAll('.result-group > li').length !== 1000 && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + const rows = document.querySelectorAll('.result-group > li').length; + if (rows !== 1000) throw Error('Expected 1000 search results, got ' + rows); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { + rows, + count: document.querySelector('.search-result-count').textContent, + match: document.querySelector('.search-match-excerpt').textContent + }; + })()`) + const readyMs = performance.now() - started + await wait(500) + window.webContents.debugger.attach('1.3') + await window.webContents.debugger.sendCommand('HeapProfiler.collectGarbage') + await wait(150) + const heap = await window.webContents.debugger.sendCommand('Runtime.getHeapUsage') + const dom = await window.webContents.debugger.sendCommand('Memory.getDOMCounters') + const metrics = app.getAppMetrics() + const processes = metrics.map(metric => ({ + type: metric.type, + serviceName: metric.serviceName, + workingSetKiB: metric.memory.workingSetSize + })) + const physicalFootprintBytes = physicalFootprint(metrics) + const queryMs = await window.webContents.executeJavaScript(`(async () => { + const input = document.querySelector('.search-box'); + const start = performance.now(); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'const value0'); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: '0', keyCode: 48 })); + const deadline = Date.now() + 30000; + const isUpdated = () => Array.from(document.querySelectorAll('.search-match-excerpt .search-match-highlight')) + .some(node => node.textContent === 'value0'); + while (!isUpdated() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 2)); + if (!isUpdated()) throw Error('Updated search did not render'); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return performance.now() - start; + })()`) + console.log('PROFILE=' + JSON.stringify({ + readyMs, + queryMs, + ui, + heapBytes: heap.usedSize, + domNodes: dom.nodes, + processes, + physicalFootprintBytes, + // Working sets include shared pages; their sum is not unique physical memory. + sumWorkingSetKiB: processes.reduce((total, metric) => total + metric.workingSetKiB, 0) + })) + app.exit(0) + } catch (error) { + console.error(error) + app.exit(1) + } +}) diff --git a/tests/performance/physicalFootprint.cjs b/tests/performance/physicalFootprint.cjs new file mode 100644 index 00000000..ae394da8 --- /dev/null +++ b/tests/performance/physicalFootprint.cjs @@ -0,0 +1,12 @@ +const { spawnSync } = require('child_process') + +// macOS's footprint tool accounts for memory not visible in resident-set sums, +// including GPU allocations. Other platforms can still use working-set data. +module.exports = function physicalFootprint (metrics) { + if (process.platform !== 'darwin') return null + const args = ['--noCategories', '-f', 'bytes', ...metrics.flatMap(metric => ['-p', String(metric.pid)])] + const result = spawnSync('/usr/bin/footprint', args, { encoding: 'utf8', timeout: 10000 }) + if (result.error || result.status !== 0) return null + const match = result.stdout.match(/Summary Footprint:\s*(\d+)\s*B/) + return match ? Number(match[1]) : null +} diff --git a/tests/smoke/electron-packaged-smoke.js b/tests/smoke/electron-packaged-smoke.js index 8d68a7c3..43f39204 100644 --- a/tests/smoke/electron-packaged-smoke.js +++ b/tests/smoke/electron-packaged-smoke.js @@ -41,6 +41,26 @@ function createTempHome (locale = 'en') { } const PACKAGED_RENDER_FIXTURES = [ + { + name: 'large-editor', + selector: '.gist-editor-viewport .CodeMirror', + text: 'Large snippet fixture|large.js' + }, + { + name: 'edit', + selector: '.modal .gist-editor-form .CodeMirror', + text: 'Edit' + }, + { + name: 'new', + selector: '.modal .gist-editor-form .CodeMirror', + text: 'New' + }, + { + name: 'asciidoc', + selector: '.asciidoc-section strong', + text: 'On-demand AsciiDoc|Formatted after loading' + }, { name: 'active', selector: '.profile-image-section', diff --git a/tests/smoke/electron-render-smoke-main.js b/tests/smoke/electron-render-smoke-main.js index 3d592197..b58d3490 100644 --- a/tests/smoke/electron-render-smoke-main.js +++ b/tests/smoke/electron-render-smoke-main.js @@ -1390,6 +1390,101 @@ async function assertFixtureImageAttachments (window) { } } +async function assertEverydayInteractions (window) { + const fixture = process.env.LEPTON_RENDER_FIXTURE + if (!['everyday', 'everyday-search'].includes(fixture)) return + await window.webContents.executeJavaScript(`(async () => { + const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + if (${JSON.stringify(fixture)} === 'everyday') { + for (const index of [1, 50, 99, 0]) { + document.querySelectorAll('.snippet-thumnail-list-item > div')[index].click(); + await frame(); + const selected = document.querySelector('.active-snippet-thumnail-private, .active-snippet-thumnail-public'); + if (!selected.textContent.includes('Utility ' + String(index).padStart(3, '0'))) throw Error('Wrong sidebar selection'); + if (!document.querySelector('.code-table').textContent.includes('const value0 = ' + index + ';')) throw Error('Wrong snippet content'); + } + } else { + const input = document.querySelector('.search-box'); + // Model input/paste without a keyup event. + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'Utility 099'); + input.dispatchEvent(new Event('input', { bubbles: true })); + await frame(); + if (document.querySelectorAll('.result-group > li').length !== 1) throw Error('Search input did not update results'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + await frame(); + if (document.querySelector('.search-box')) throw Error('Search selection did not close search'); + if (!document.querySelector('.code-table').textContent.includes('const value0 = 99;')) throw Error('Search opened wrong snippet'); + } + })()`) +} + +async function assertLargeSnippetRendering (window) { + const fixture = process.env.LEPTON_RENDER_FIXTURE + if (!['large-collapsed', 'large-editor'].includes(fixture)) return + + if (fixture === 'large-collapsed') { + await window.webContents.executeJavaScript(`(async () => { + const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const toggle = document.querySelector('.file-expand'); + const content = document.querySelector('.code-area'); + if (!content || content.getClientRects().length) throw Error('Collapsed file is missing or visible'); + for (let cycle = 0; cycle < 2; cycle++) { + toggle.click(); + await frame(); + const rows = content.querySelectorAll('.code-table tr'); + if (document.querySelector('.code-area') !== content || !content.getClientRects().length || + rows.length !== 10000 || !rows[9999].textContent.includes('item9999')) throw Error('Expanded file lost its mounted content'); + toggle.click(); + await frame(); + if (document.querySelector('.code-area') !== content || content.getClientRects().length) throw Error('Collapsed file did not retain hidden content'); + } + })()`) + return + } + + await window.webContents.executeJavaScript(`(async () => { + const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const cm = document.querySelector('.CodeMirror').CodeMirror; + const original = cm.getValue(); + const assertViewport = () => { + const bounds = cm.getWrapperElement().getBoundingClientRect(); + const visibleLines = document.querySelectorAll('.CodeMirror-code > div').length; + if (cm.lineCount() !== 10000 || cm.getOption('viewportMargin') !== 10 || visibleLines > 200 || bounds.height > 650) { + throw Error('Large editor is not bounded: ' + JSON.stringify({ visibleLines, height: bounds.height })); + } + }; + assertViewport(); + cm.setValue('const small = true'); + await frame(); + if (document.querySelector('.gist-editor-viewport') || cm.getOption('viewportMargin') !== Infinity) throw Error('Small editor did not restore natural height'); + cm.setValue(original); + await frame(); + assertViewport(); + cm.setCursor({ line: 9999, ch: cm.getLine(9999).length }); + cm.scrollIntoView(cm.getCursor(), 40); + cm.focus(); + await frame(); + if (cm.getViewport().to < 10000) throw Error('Last line is not reachable'); + })()`) + window.focus() + window.webContents.focus() + await window.webContents.executeJavaScript("document.querySelector('.CodeMirror').CodeMirror.focus()", true) + await window.webContents.insertText(' // viewport smoke') + await window.webContents.executeJavaScript(`(async () => { + const cm = document.querySelector('.CodeMirror').CodeMirror; + const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const deadline = Date.now() + 2000; + while (!cm.getLine(9999).endsWith(' // viewport smoke') && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20)); + if (cm.lineCount() !== 10000 || !cm.getLine(9999).endsWith(' // viewport smoke')) throw Error('Typing at the end of the large file failed: ' + JSON.stringify({ line: cm.getLine(9999), focused: cm.hasFocus(), cursor: cm.getCursor() })); + cm.undo(); + await frame(); + if (cm.getLine(9999) !== 'const item9999 = "example 9999";') throw Error('Undo lost file content'); + cm.setCursor({ line: 0, ch: 0 }); + cm.scrollIntoView(cm.getCursor()); + await frame(); + })()`) +} + async function main () { let window @@ -1402,6 +1497,8 @@ async function main () { await waitForFixtureUi(window) await applyFixtureFilterClicks(window) assertFixtureRendererState(await getRendererState(window)) + await assertEverydayInteractions(window) + await assertLargeSnippetRendering(window) await assertFixtureLoginModeSwitch(window) await assertFixturePageFind(window) if (process.env.LEPTON_RENDER_FIXTURE === 'new-draft') { diff --git a/tests/smoke/electron-render-smoke.js b/tests/smoke/electron-render-smoke.js index cca72ab9..3f0492c3 100644 --- a/tests/smoke/electron-render-smoke.js +++ b/tests/smoke/electron-render-smoke.js @@ -38,6 +38,31 @@ function createTempHome (locale = 'en', configOverrides = {}) { } const RENDER_FIXTURES = [ + { + name: 'everyday', + selector: '.code-table', + text: 'Utility 000|helper.js' + }, + { + name: 'everyday-search', + selector: '.search-result-count', + text: '100 snippets found' + }, + { + name: 'large-collapsed', + selector: '.file-header .file-expand', + text: 'Large snippet fixture|large.js' + }, + { + name: 'large-editor', + selector: '.gist-editor-viewport .CodeMirror', + text: 'Large snippet fixture|large.js' + }, + { + name: 'asciidoc', + selector: '.asciidoc-section strong', + text: 'On-demand AsciiDoc|Formatted after loading' + }, { name: 'active', selector: '.active-layout .snippet-box', diff --git a/tests/utilities/search.test.js b/tests/utilities/search.test.js index ef2b0c04..60f78f62 100644 --- a/tests/utilities/search.test.js +++ b/tests/utilities/search.test.js @@ -100,6 +100,22 @@ describe('search utility', () => { ]) }) + it('retains only excerpt highlights for content matches while searching the entire file', () => { + const content = Array.from({ length: 100 }, (_, i) => `const helper${i} = ${i};`).join('\n') + SearchIndex.resetFuseIndex([SearchIndex.buildSearchRecord({ + id: 'many-lines', description: 'Utility', + files: { 'helper.js': { filename: 'helper.js', language: 'JavaScript', content } } + })]) + const [result] = SearchIndex.fuseSearch('const helper99') + expect(result.id).toBe('many-lines') + const match = result.searchMeta.matches.find(match => match.key === 'files.content') + expect(match.segments).toBe(match.excerpt.segments) + expect(match.segments.length).toBeLessThan(10) + expect(match.excerpt.segments).toContainEqual({ text: 'const', match: true }) + expect(SearchIndex.fuseSearch('helper99')[0].searchMeta.bestMatch.excerpt.segments) + .toContainEqual({ text: 'helper99', match: true }) + }) + it('keeps image filenames searchable without indexing Base64 payloads', () => { const imageContent = Buffer.from([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, diff --git a/webpack.config.js b/webpack.config.js index 9253667d..b3c96f70 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -34,7 +34,7 @@ module.exports = { loader: 'babel-loader', options: { presets: [ - ['@babel/preset-env', { modules: false }], + ['@babel/preset-env', { modules: false, targets: { electron: '42' } }], '@babel/preset-react' ] } @@ -43,6 +43,8 @@ module.exports = { use: [ 'style-loader', 'css-loader', + // Run after Sass, before css-loader resolves and emits font assets. + path.resolve(__dirname, 'configs/loaders/woff2Only.js'), { loader: 'sass-loader', options: {