Skip to content
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

feature: Implement Search Settings Ability #416

Merged
merged 16 commits into from Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/settings-view/lib/search-setting-view.js
@@ -0,0 +1,84 @@
/** @babel */
/** @jsx etch.dom */

import etch from 'etch'
import { shell } from 'electron'
import { Disposable, CompositeDisposable } from 'atom'

export default class SearchSettingView {
constructor(setting, settingsView) {
this.settingsView = settingsView
this.setting = setting
this.disposables = new CompositeDisposable()

etch.initialize(this)

this.handleButtonEvents()
}

render () {
const title = this.setting.title ?? "";
const path = this.setting.path;
const description = this.setting.description ?? "";

return (
<div className='package-card col-lg-8'>
<div className='body'>
<h4 className='card-name'>
<a ref='settingLink'>
<span className='package-name'>{title}</span>
<span className='value'>{path}</span>
</a>
</h4>
<span className='package-description'>{description}</span>

</div>

</div>
)
}

update () {}

destroy () {
this.disposables.dispose()
return etch.destroy(this)
}

handleButtonEvents () {
const settingsClickHandler = (event) => {
event.stopPropagation()
//this.settingsView.showPanelForURI("atom://settings-view")

// Lets check if the setting we want to open is built in or from a package
const settingLocation = this.setting.path.split(".")[0]
// The above is the location where the setting exists, such as Core, or a packages name

switch(settingLocation) {
case "core":
// There are some special cases of settings broken off into other panels
let settingName = this.setting.path.split(".")[1]
if (settingName === 'uriHandlerRegistration') {
// the URI handler doesn't have any registered uri to actually reach it
// funnily enough. So we will prompt a notification to go there
atom.notifications.addInfo("Sorry, Pulsar is unable to link to this setting. Please select 'URI Handling' on the sidebar.")
} else {
atom.workspace.open("atom://config/core")
}
break;
case "editor":
atom.workspace.open("atom://config/editor")
break;
default:
// The handling for any packages name
atom.workspace.open(`atom://config/packages/${settingLocation}`)
Copy link
Contributor

@icecream17 icecream17 Mar 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a difference between builtin packages and installed packages that makes installed packages not work?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surprisingly the URI for the settings page of all packages is exactly the same. Made sure this functioned for either the core packages or community packages.

Only thing not explicitly tested for is Git installed packages, but I'd imagine they should work as well, especially since the settings-view showPackage() function is written identically.

But of course anyone with Git installed packages on their system could double check by navigating to this URI format in the browser and letting it open up Pulsar

Copy link
Sponsor Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been using the package-settings package for ages in order to jump quickly to a given package's settings, and this is the exact method that package uses. Never observed it not to work, no matter how a package is installed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I got confused, that's relieving. I think for clarity, the packages caveat means:

  • Does not search for the names of any packages

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I got confused, that's relieving. I think for clarity, the packages caveat means:

  • Does not search for the names of any packages

Ahh I see how that can be confusing. Yeah just means it's not the search that's available on the "Packages" pane, as in it won't accomplish the same thing.

(Although technically you could search by package name to help bring your package up, but it will only show results for packages that have settings. As the name of the package is considered when doing a search. But this makes the whole thing a little confusing, so though it was easier to say it doesn't search for packages)

break;
}
//atom.workspace.open("atom://config/core/closeDeletedFileTabs")
// Open the relevant settings page
}

this.refs.settingLink.addEventListener('click', settingsClickHandler)
this.disposables.add(new Disposable(() => { this.refs.settingLink.removeEventListener('click', settingsClickHandler) }))
}
}
249 changes: 249 additions & 0 deletions packages/settings-view/lib/search-settings-panel.js
@@ -0,0 +1,249 @@
/** @babel */
/** @jsx etch.dom */

import { TextEditor, CompositeDisposable } from 'atom'
import etch from 'etch'
import CollapsibleSectionPanel from './collapsible-section-panel'
import SearchSettingView from './search-setting-view'

export default class SearchSettingsPanel extends CollapsibleSectionPanel {
constructor(settingsView) {
super()
etch.initialize(this)
this.settingsView = settingsView
this.searchResults = []
// Get all available settings
this.settingsSchema = atom.config.schema.properties;

this.subscriptions = new CompositeDisposable()
this.subscriptions.add(this.handleEvents())
this.subscriptions.add(atom.commands.add(this.element, {
'core:move-up': () => { this.scrollUp() },
'core:move-down': () => { this.scrollDown() },
'core:page-up': () => { this.pageUp() },
'core:page-down': () => { this.pageDown() },
'core:move-to-top': () => { this.scrollToTop() },
'core:move-to-bottom': () => { this.scrollToBottom() }
}))

this.subscriptions.add(
this.refs.searchEditor.onDidStopChanging(() => { this.matchSettings() })
)
}

focus () {
this.refs.searchEditor.element.focus()
}

show () {
this.element.style.display = ''
// Don't show the loading for search results as soon as page appears
this.refs.loadingArea.style.display = 'none'
}

destroy () {
this.subscriptions.dispose()
return etch.destroy(this)
}

update () {}

render () {
return (
<div className='panels-item' tabIndex='-1'>
<section className='section'>
<div className='section-container'>
<div className='section-heading icon icon-search-save'>
Search Pulsar's Settings
</div>
<div className='editor-container'>
<TextEditor ref='searchEditor' mini placeholderText='Start Searching for Settings' />
</div>

<section className='sub-section search-results'>
<h3 ref='searchHeader' className='sub-section-heading icon icon-package'>
Search Results
</h3>
<div ref='searchResults' className='container package-container'>
<div ref='loadingArea' className='alert alert-info loading-area icon icon-hourglass'>
Loading Results...
</div>
</div>
</section>

</div>
</section>
</div>
)
}

matchSettings () {
// this is called after the user types.
// So lets show our loading message after removing any previous results
this.clearSearchResults()
this.refs.loadingArea.style.display = ''
this.filterSettings(this.refs.searchEditor.getText())
}

clearSearchResults () {
for (let i = 0; i < this.searchResults.length; i++) {
this.searchResults[i].destroy()
}
this.searchResults = []
}

filterSettings (text) {
let rankedResults = [];

for (const setting in this.settingsSchema) {
// The top level item should always be an object, but just in case we will check.
// If the top level item returned is not an object it will NOT be listed
if (this.settingsSchema[setting].type === "object") {
for (const item in this.settingsSchema[setting].properties) {

// Now to generate results for the top level settings within each package
// or area of settings such as `core` or `find-and-replace`.
// We will also still descend one level further if we find an object
const passString = (string) => {
return string?.toLowerCase() ?? "";
};

let schema = this.settingsSchema[setting].properties[item];

let rankedTitle = this.getScore(text, passString(schema.title));
let rankedDescription = this.getScore(text, passString(schema.description));
let rankedSettingName = this.getScore(text, passString(setting));
let rankedSettingItem = this.getScore(text, passString(item));
schema.rank = {
title: rankedTitle,
description: rankedDescription,
settingName: rankedSettingName,
settingItem: rankedSettingItem
};
schema.path = `${setting}.${item}`;

// Now to calculate the total score of the search results.
// The total score will be a sum of all individual scores, with
// weighted bonus' for higher matches depending on where the match was
let titleBonus = (schema.rank.title.score > 0.8) ? 0.2 : 0;
let perfectTitleBonus = (schema.rank.title.score === 1) ? 0.1 : 0;
let descriptionBonus = (schema.rank.description.score > 0.5) ? 0.1 : 0;
let perfectDescriptionBonus = (schema.rank.title.score === 1) ? 0.1 : 0;
let settingNameBonus = (schema.rank.settingName.score > 0.8) ? 0.2 : 0;
let perfectSettingNameBonus = (schema.rank.title.score === 1) ? 0.1 : 0;
let settingItemBonus = (schema.rank.settingItem.score > 0.8) ? 0.2 : 0;
let perfectSettingItemBonus = (schema.rank.settingItem.score === 1) ? 0.1 : 0;
let totalScore =
schema.rank.title.score + titleBonus + perfectTitleBonus
+ schema.rank.description.score + descriptionBonus + perfectDescriptionBonus
+ schema.rank.settingName.score + settingNameBonus + perfectSettingNameBonus
+ schema.rank.settingItem.score + settingItemBonus + perfectSettingItemBonus;
schema.rank.totalScore = totalScore;
rankedResults.push(schema);
}
}
}

this.processRanks(rankedResults)
}

processRanks (ranks) {
// Gets an array of schemas with ranks included

// Removes any scores below a specific limit
let filteredRanks = ranks.filter(item => item.rank.totalScore > atom.config.get("settings-view.searchSettingsMinimumScore"));

// Sorts the array from highest score to lowest score
filteredRanks.sort((a, b) => {
if (a.rank.totalScore < b.rank.totalScore) {
return 1;
}
if (a.rank.totalScore > b.rank.totalScore) {
return -1;
}
return 0;
});

// Remove our loading symbol
this.refs.loadingArea.style.display = 'none'

for (const setting of filteredRanks) {
let searchView = new SearchSettingView(setting, this.settingsView)
this.refs.searchResults.appendChild(searchView.element)
this.searchResults.push(searchView)
}

}

getScore (s1, s2) {
// s1 is the text we are calculating the score against
// s2 is the text the user typed
// Below is an exact implmentation of Longest Common Subsequence

let height = s1.length + 1;
let width = s2.length + 1;
let matrix = Array(height)
.fill(0)
.map(() => Array(width).fill(0));

for (let row = 1; row < height; row++) {
for (let col = 1; col < width; col++) {
if (s1[row - 1] == s2[col - 1]) {
matrix[row][col] = matrix[row - 1][col - 1] + 1;
} else {
matrix[row][col] = Math.max(matrix[row][col - 1], matrix[row - 1][col]);
}
}
}

let longest = this.lcsTraceback(matrix, s1, s2, height, width);
// Now longest is a literal string of the longest common subsequence.
// We will now assign a score to help ranking, but will still return the
// text sequence, in case we want to use that for display purposes
return {
score: longest.length / s1.length,
sequence: longest
};
}

lcsTraceback (matrix, s1, s2, height, width) {
if (height === 0 || width === 0) {
return "";
}
if (s1[height - 1] == s2[width - 1]) {
return (
this.lcsTraceback(matrix, s1, s2, height - 1, width - 1) +
(s1[height - 1] ? s1[height - 1] : "")
);
}
if (matrix[height][width - 1] > matrix[height - 1][width]) {
return this.lcsTraceback(matrix, s1, s2, height, width - 1);
}
return this.lcsTraceback(matrix, s1, s2, height - 1, width);
}

// Boiler Plate Functions
scrollUp () {
this.element.scrollTop -= document.body.offsetHeight / 20
}

scrollDown () {
this.element.scrollTop += document.body.offsetHeight / 20
}

pageUp () {
this.element.scrollTop -= this.element.offsetHeight
}

pageDown () {
this.element.scrollTop += this.element.offsetHeight
}

scrollToTop () {
this.element.scrollTop = 0
}

scrollToBottom () {
this.element.scrollTop = this.element.scrollHeight
}
}
2 changes: 2 additions & 0 deletions packages/settings-view/lib/settings-view.js
Expand Up @@ -15,6 +15,7 @@ import ThemesPanel from './themes-panel'
import InstalledPackagesPanel from './installed-packages-panel'
import UpdatesPanel from './updates-panel'
import UriHandlerPanel from './uri-handler-panel'
import SearchSettingsPanel from './search-settings-panel'

export default class SettingsView {
constructor ({uri, packageManager, snippetsProvider, activePanel} = {}) {
Expand Down Expand Up @@ -120,6 +121,7 @@ export default class SettingsView {
this.refs.openDotAtom.addEventListener('click', openDotAtomClickHandler)
this.disposables.add(new Disposable(() => this.refs.openDotAtom.removeEventListener('click', openDotAtomClickHandler)))

this.addCorePanel('Search', 'search', () => new SearchSettingsPanel(this))
this.addCorePanel('Core', 'settings', () => new GeneralPanel())
this.addCorePanel('Editor', 'code', () => new EditorPanel())
if (atom.config.getSchema('core.uriHandlerRegistration').type !== 'any') {
Expand Down
6 changes: 6 additions & 0 deletions packages/settings-view/package.json
Expand Up @@ -14,6 +14,12 @@
"description": "Limit how many processes run simultaneously during package updates. If your machine slows down while updating many packages at once, set this value to a small positive number (e.g., `1` or `2`).",
"type": "integer",
"default": -1
},
"searchSettingsMinimumScore": {
"title": "Search Settings Minimum Score to Display Results",
"description": "Set the minimum similarity score required for a setting to appear in the search results, when searching for settings.",
"type": "integer",
"default": 2
}
},
"dependencies": {
Expand Down