jupyterlab_vscode_icons_extension 1.1.8 → 1.1.22

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.
package/README.md CHANGED
@@ -21,7 +21,7 @@ This extension brings 1414 beautiful file type icons from the vscode-icons proje
21
21
  - Zero configuration required - just install and enjoy
22
22
  - Lightweight integration using Iconify's JSON icon format
23
23
  - Compatible with Jupytext - properly displays Python and Markdown icons for .py and .md notebook files
24
- - No folders (we left those alone... for now)
24
+ - Python package folder detection - folders declared in `pyproject.toml` or `setup.py` get a special Python package icon
25
25
 
26
26
  ## Requirements
27
27
 
package/lib/index.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { PageConfig, URLExt } from '@jupyterlab/coreutils';
1
2
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
2
3
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
3
4
  import { LabIcon } from '@jupyterlab/ui-components';
4
5
  import { getIconSVG } from './icons';
6
+ import { parsePyprojectToml, parseSetupPy } from './parsers';
5
7
  const PLUGIN_ID = 'jupyterlab_vscode_icons_extension:plugin';
6
8
  /**
7
9
  * Create a LabIcon from vscode-icons SVG data
@@ -127,7 +129,7 @@ const fileTypeConfigs = [
127
129
  iconName: 'file-type-perl',
128
130
  group: 'enableLanguageIcons'
129
131
  },
130
- // Shell scripts (.sh, .bash, .zsh) and batch files (.bat, .cmd) use custom icons with black backgrounds
132
+ // Shell scripts (.sh, .bash, .zsh, .fish, .csh, .nu) and batch files (.bat, .cmd) use custom icons with black backgrounds
131
133
  // Registered separately below with custom SVGs
132
134
  {
133
135
  extensions: ['.ps1'],
@@ -442,6 +444,11 @@ const plugin = {
442
444
  const pytestDataUri = `data:image/svg+xml;base64,${btoa(pytestSvg)}`;
443
445
  const pythonPackageSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="ppa" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0" stop-color="#387eb8"/><stop offset="1" stop-color="#366994"/></linearGradient><linearGradient id="ppb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0" stop-color="#ffe052"/><stop offset="1" stop-color="#ffc331"/></linearGradient></defs><g transform="scale(-1,1) translate(-32,0)"><path d="M27.4,5.5H18.1L16,9.7H4.3V26.5H29.5V5.5Zm0,4.2H19.2l1.1-2.1h7.1Z" fill="#58af7b"/><path d="M20.9,11c-5.1,0-4.8,2.2-4.8,2.2v2.3H21v.7H14.2S11,15.8,11,21s2.9,5,2.9,5h1.7V23.6a2.7,2.7,0,0,1,2.8-2.9h4.8a2.6,2.6,0,0,0,2.7-2.6V13.7S26.2,11,20.9,11Zm-2.7,1.5a.9.9,0,1,1-.8.9.9.9,0,0,1,.8-.9Z" fill="url(#ppa)"/><path d="M21.1,31c5.1,0,4.8-2.2,4.8-2.2V26.5H21v-.7h6.8S31,26.1,31,21s-2.9-5-2.9-5h-1.7v2.4a2.7,2.7,0,0,1-2.8,2.9H18.8a2.6,2.6,0,0,0-2.7,2.6v4.4S15.7,31,21,31Zm2.7-1.5a.9.9,0,1,1,.8-.9.9.9,0,0,1-.8.9Z" fill="url(#ppb)"/></g></svg>';
444
446
  const pythonPackageDataUri = `data:image/svg+xml;base64,${btoa(pythonPackageSvg)}`;
447
+ const venvSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><g transform="scale(-1,1) translate(-32,0)"><path d="M27.4,5.5H18.1L16,9.7H4.3V26.5H29.5V5.5Zm0,4.2H19.2l1.1-2.1h7.1Z" fill="#9575cd"/><g transform="translate(22,22) scale(1.25)" fill="#bababa"><path d="M-1.2,-6 L1.2,-6 L1.5,-4.5 L2.8,-4 L4,-5 L5.5,-3.5 L4.5,-2.3 L5,-1 L6.5,-0.8 L6.5,1.2 L5,1.5 L4.5,2.8 L5.5,4 L4,5.5 L2.8,4.5 L1.5,5 L1.2,6.5 L-1.2,6.5 L-1.5,5 L-2.8,4.5 L-4,5.5 L-5.5,4 L-4.5,2.8 L-5,1.5 L-6.5,1.2 L-6.5,-0.8 L-5,-1 L-4.5,-2.3 L-5.5,-3.5 L-4,-5 L-2.8,-4 L-1.5,-4.5 Z"/><circle cx="0" cy="0" r="2.5" fill="#9575cd"/></g></g></svg>';
448
+ const venvDataUri = `data:image/svg+xml;base64,${btoa(venvSvg)}`;
449
+ // Executable file icon - JupyterLab standard file icon with play triangle overlay
450
+ const executableSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22"><path fill="#616161" d="m19.3 8.2-5.5-5.5c-.3-.3-.7-.5-1.2-.5H3.9c-.8.1-1.6.9-1.6 1.8v14.1c0 .9.7 1.6 1.6 1.6h14.2c.9 0 1.6-.7 1.6-1.6V9.4c.1-.5-.1-.9-.4-1.2m-5.8-3.3 3.4 3.6h-3.4zm3.9 12.7H4.7c-.1 0-.2 0-.2-.2V4.7c0-.2.1-.3.2-.3h7.2v4.4s0 .8.3 1.1 1.1.3 1.1.3h4.3v7.2s-.1.2-.2.2"/><path fill="#66bb6a" d="M12,12 L20,16 L12,20 Z"/></svg>';
451
+ const executableDataUri = `data:image/svg+xml;base64,${btoa(executableSvg)}`;
445
452
  // Inject CSS that overrides icons for .py and .md files
446
453
  // Note: Jupytext marks .py and .md files as type="notebook", so we need to
447
454
  // use JavaScript to detect and mark these files for CSS targeting
@@ -645,6 +652,38 @@ const plugin = {
645
652
  background-repeat: no-repeat;
646
653
  background-position: center;
647
654
  }
655
+
656
+ /* Override venv folder icons (.venv, venv, .env, env) */
657
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon svg,
658
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon img {
659
+ display: none !important;
660
+ }
661
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon::before {
662
+ content: '';
663
+ display: inline-block;
664
+ width: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
665
+ height: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
666
+ background-image: url('${venvDataUri}');
667
+ background-size: contain;
668
+ background-repeat: no-repeat;
669
+ background-position: center;
670
+ }
671
+
672
+ /* Override executable file icons */
673
+ .jp-DirListing-item[data-executable] .jp-DirListing-itemIcon svg,
674
+ .jp-DirListing-item[data-executable] .jp-DirListing-itemIcon img {
675
+ display: none !important;
676
+ }
677
+ .jp-DirListing-item[data-executable] .jp-DirListing-itemIcon::before {
678
+ content: '';
679
+ display: inline-block;
680
+ width: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
681
+ height: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
682
+ background-image: url('${executableDataUri}');
683
+ background-size: contain;
684
+ background-repeat: no-repeat;
685
+ background-position: center;
686
+ }
648
687
  `;
649
688
  // Add CSS to make JavaScript and .env icons less bright
650
689
  style.textContent += `
@@ -666,71 +705,91 @@ const plugin = {
666
705
  opacity: 55% !important;
667
706
  }
668
707
  `;
669
- // Cache for Python package folder checks
670
- const pythonPackageCache = new Map();
671
- // Folders to exclude from Python package checking
672
- const excludedFolderPatterns = [
673
- /^\.git$/,
674
- /^\.hg$/,
675
- /^\.svn$/,
676
- /^\.venv$/,
677
- /^venv$/,
678
- /^\.env$/,
679
- /^env$/,
680
- /^node_modules$/,
681
- /^__pycache__$/,
682
- /^\.ipynb_checkpoints$/,
683
- /^\.pytest_cache$/,
684
- /^\.mypy_cache$/,
685
- /^\.ruff_cache$/,
686
- /^\.tox$/,
687
- /^\.nox$/,
688
- /^\.coverage$/,
689
- /^htmlcov$/,
690
- /^dist$/,
691
- /^build$/,
692
- /^\.eggs$/,
693
- /\.egg-info$/,
694
- /\.dist-info$/
695
- ];
696
- const isExcludedFolder = (name) => {
697
- return excludedFolderPatterns.some(pattern => pattern.test(name));
698
- };
699
- // Check if a folder is a Python package (contains __init__.py)
700
- // Use the file browser's model to get the correct path
701
- const checkPythonPackage = async (folderName) => {
702
- var _a;
703
- // Skip excluded folders
704
- if (isExcludedFolder(folderName)) {
705
- return false;
708
+ // Cache for Python package detection (per directory)
709
+ let pythonPackagesCache = null;
710
+ // Detect Python packages by parsing pyproject.toml or setup.py
711
+ const detectPythonPackages = async () => {
712
+ const currentPath = (defaultFileBrowser === null || defaultFileBrowser === void 0 ? void 0 : defaultFileBrowser.model.path) || '';
713
+ // Return cached result if same directory
714
+ if ((pythonPackagesCache === null || pythonPackagesCache === void 0 ? void 0 : pythonPackagesCache.path) === currentPath) {
715
+ return pythonPackagesCache.packages;
716
+ }
717
+ const packages = new Set();
718
+ try {
719
+ const contents = await app.serviceManager.contents.get(currentPath, {
720
+ content: true
721
+ });
722
+ const files = contents.content || [];
723
+ // Check for pyproject.toml
724
+ const hasPyproject = files.some((f) => f.name === 'pyproject.toml');
725
+ if (hasPyproject) {
726
+ const pyprojectPath = currentPath
727
+ ? `${currentPath}/pyproject.toml`
728
+ : 'pyproject.toml';
729
+ const file = await app.serviceManager.contents.get(pyprojectPath);
730
+ const content = file.content;
731
+ const parsed = parsePyprojectToml(content);
732
+ parsed.forEach(p => packages.add(p));
733
+ }
734
+ // Check for setup.py
735
+ const hasSetupPy = files.some((f) => f.name === 'setup.py');
736
+ if (hasSetupPy) {
737
+ const setupPath = currentPath
738
+ ? `${currentPath}/setup.py`
739
+ : 'setup.py';
740
+ const file = await app.serviceManager.contents.get(setupPath);
741
+ const content = file.content;
742
+ const parsed = parseSetupPy(content);
743
+ parsed.forEach(p => packages.add(p));
744
+ }
706
745
  }
707
- // Get the current path from the file browser model
708
- if (!defaultFileBrowser) {
709
- return false;
746
+ catch (_a) {
747
+ // Ignore errors - no packages detected
710
748
  }
711
- const currentPath = defaultFileBrowser.model.path;
712
- const fullPath = currentPath
713
- ? `${currentPath}/${folderName}`
714
- : folderName;
715
- if (pythonPackageCache.has(fullPath)) {
716
- return pythonPackageCache.get(fullPath);
749
+ pythonPackagesCache = { path: currentPath, packages };
750
+ return packages;
751
+ };
752
+ // Cache for executable file detection (per directory)
753
+ let executablesCache = null;
754
+ // Detect executable files via server API
755
+ const detectExecutables = async () => {
756
+ if (!settings.enableExecutableIcons) {
757
+ return new Set();
717
758
  }
759
+ const currentPath = (defaultFileBrowser === null || defaultFileBrowser === void 0 ? void 0 : defaultFileBrowser.model.path) || '';
760
+ // Return cached result if same directory
761
+ if ((executablesCache === null || executablesCache === void 0 ? void 0 : executablesCache.path) === currentPath) {
762
+ return executablesCache.executables;
763
+ }
764
+ const executables = new Set();
718
765
  try {
719
- // Use JupyterLab's contents manager with the correct path from file browser
720
- const contents = app.serviceManager.contents;
721
- const model = await contents.get(fullPath, { content: true });
722
- const hasInit = ((_a = model.content) === null || _a === void 0 ? void 0 : _a.some((item) => item.name === '__init__.py')) ||
723
- false;
724
- pythonPackageCache.set(fullPath, hasInit);
725
- return hasInit;
766
+ const baseUrl = PageConfig.getBaseUrl();
767
+ const apiUrl = URLExt.join(baseUrl, 'vscode-icons', 'executables');
768
+ const response = await fetch(`${apiUrl}?path=${encodeURIComponent(currentPath)}`);
769
+ if (response.ok) {
770
+ const files = await response.json();
771
+ files.forEach(f => executables.add(f));
772
+ }
726
773
  }
727
- catch (_b) {
728
- pythonPackageCache.set(fullPath, false);
729
- return false;
774
+ catch (_a) {
775
+ // Ignore errors - no executables detected
730
776
  }
777
+ executablesCache = { path: currentPath, executables };
778
+ return executables;
731
779
  };
780
+ // Invalidate cache on directory change
781
+ if (defaultFileBrowser) {
782
+ defaultFileBrowser.model.pathChanged.connect(() => {
783
+ pythonPackagesCache = null;
784
+ executablesCache = null;
785
+ });
786
+ }
732
787
  // Add a MutationObserver to mark special files in the file browser
733
- const markSpecialFiles = () => {
788
+ const markSpecialFiles = async () => {
789
+ // Get Python packages for current directory (cached)
790
+ const pythonPackages = await detectPythonPackages();
791
+ // Get executable files for current directory (cached, only if setting enabled)
792
+ const executables = await detectExecutables();
734
793
  // Process ALL items - clear wrong attributes and set correct ones
735
794
  const allItems = document.querySelectorAll('.jp-DirListing-item');
736
795
  allItems.forEach(item => {
@@ -809,29 +868,48 @@ const plugin = {
809
868
  nameLower === 'conftest.py') {
810
869
  item.setAttribute('data-pytest', 'true');
811
870
  }
871
+ // Mark executable files if setting is enabled (uses server API for +x detection)
872
+ item.removeAttribute('data-executable');
873
+ if (settings.enableExecutableIcons && executables.has(name)) {
874
+ item.setAttribute('data-executable', 'true');
875
+ }
812
876
  // Check if this is a directory (folder)
813
877
  const isDir = fileType === 'directory' ||
814
878
  item.classList.contains('jp-DirListing-directory');
815
879
  if (isDir) {
816
- // Check if this folder is a Python package (async)
817
- // Pass just the folder name - checkPythonPackage gets current path from file browser
818
- checkPythonPackage(name).then(isPythonPackage => {
819
- if (isPythonPackage) {
880
+ // Check if folder is a venv folder (.venv, venv, .env, env)
881
+ const venvNames = ['.venv', 'venv', '.env', 'env'];
882
+ if (venvNames.includes(nameLower)) {
883
+ item.setAttribute('data-venv-folder', 'true');
884
+ item.removeAttribute('data-python-package');
885
+ }
886
+ else {
887
+ item.removeAttribute('data-venv-folder');
888
+ // Check if folder name matches a detected Python package
889
+ if (pythonPackages.has(name)) {
820
890
  item.setAttribute('data-python-package', 'true');
821
891
  }
822
892
  else {
823
893
  item.removeAttribute('data-python-package');
824
894
  }
825
- });
895
+ }
826
896
  }
827
897
  else {
828
898
  item.removeAttribute('data-python-package');
899
+ item.removeAttribute('data-venv-folder');
829
900
  }
830
901
  });
831
902
  };
832
- // Watch for changes in the file browser
903
+ // Debounce timeout for MutationObserver
904
+ let markSpecialFilesTimeout = null;
905
+ // Watch for changes in the file browser (debounced)
833
906
  const observer = new MutationObserver(() => {
834
- markSpecialFiles();
907
+ if (markSpecialFilesTimeout) {
908
+ clearTimeout(markSpecialFilesTimeout);
909
+ }
910
+ markSpecialFilesTimeout = setTimeout(() => {
911
+ markSpecialFiles();
912
+ }, 100);
835
913
  });
836
914
  // Start observing when the file browser is ready
837
915
  setTimeout(() => {
@@ -862,7 +940,8 @@ const plugin = {
862
940
  enableDataIcons: true,
863
941
  enableConfigIcons: true,
864
942
  enableDocIcons: true,
865
- enableImageIcons: true
943
+ enableImageIcons: true,
944
+ enableExecutableIcons: false
866
945
  };
867
946
  // Function to register file types based on settings
868
947
  const registerFileTypes = () => {
@@ -1015,7 +1094,7 @@ const plugin = {
1015
1094
  docRegistry.addFileType({
1016
1095
  name: 'vscode-shell',
1017
1096
  displayName: 'Shell Script',
1018
- extensions: ['.sh', '.bash', '.zsh'],
1097
+ extensions: ['.sh', '.bash', '.zsh', '.fish', '.csh', '.nu'],
1019
1098
  fileFormat: 'text',
1020
1099
  contentType: 'file',
1021
1100
  icon: shellIcon
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Python package configuration file parsers
3
+ * Extracts package names from pyproject.toml and setup.py files
4
+ */
5
+ /**
6
+ * Parse pyproject.toml content to extract Python package names
7
+ * @param content - The content of pyproject.toml file
8
+ * @returns Set of package names found in the file
9
+ */
10
+ export declare function parsePyprojectToml(content: string): Set<string>;
11
+ /**
12
+ * Parse setup.py content to extract Python package names
13
+ * @param content - The content of setup.py file
14
+ * @returns Set of package names found in the file
15
+ */
16
+ export declare function parseSetupPy(content: string): Set<string>;
package/lib/parsers.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Python package configuration file parsers
3
+ * Extracts package names from pyproject.toml and setup.py files
4
+ */
5
+ /**
6
+ * Parse pyproject.toml content to extract Python package names
7
+ * @param content - The content of pyproject.toml file
8
+ * @returns Set of package names found in the file
9
+ */
10
+ export function parsePyprojectToml(content) {
11
+ const packages = new Set();
12
+ // Parse [project] name = "package_name"
13
+ const nameMatch = content.match(/\[project\][^[]*name\s*=\s*["']([^"']+)["']/s);
14
+ if (nameMatch) {
15
+ packages.add(nameMatch[1]);
16
+ // Also add underscore variant (package-name -> package_name)
17
+ packages.add(nameMatch[1].replace(/-/g, '_'));
18
+ }
19
+ // Parse packages = ["pkg1", "pkg2"]
20
+ const packagesMatch = content.match(/packages\s*=\s*\[([^\]]+)\]/);
21
+ if (packagesMatch) {
22
+ const pkgList = packagesMatch[1].match(/["']([^"']+)["']/g);
23
+ pkgList === null || pkgList === void 0 ? void 0 : pkgList.forEach(p => packages.add(p.replace(/["']/g, '')));
24
+ }
25
+ return packages;
26
+ }
27
+ /**
28
+ * Parse setup.py content to extract Python package names
29
+ * @param content - The content of setup.py file
30
+ * @returns Set of package names found in the file
31
+ */
32
+ export function parseSetupPy(content) {
33
+ const packages = new Set();
34
+ // Parse packages=["pkg1", "pkg2"]
35
+ const packagesMatch = content.match(/packages\s*=\s*\[([^\]]+)\]/);
36
+ if (packagesMatch) {
37
+ const pkgList = packagesMatch[1].match(/["']([^"']+)["']/g);
38
+ pkgList === null || pkgList === void 0 ? void 0 : pkgList.forEach(p => packages.add(p.replace(/["']/g, '')));
39
+ }
40
+ // Parse name="package_name"
41
+ const nameMatch = content.match(/name\s*=\s*["']([^"']+)["']/);
42
+ if (nameMatch) {
43
+ packages.add(nameMatch[1]);
44
+ packages.add(nameMatch[1].replace(/-/g, '_'));
45
+ }
46
+ return packages;
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_vscode_icons_extension",
3
- "version": "1.1.8",
3
+ "version": "1.1.22",
4
4
  "description": "Jupyterlab extension with a shameless rip-off of the vscode-icons into our beloved environment",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Tests for Python package configuration file parsers
3
+ */
4
+
5
+ import { parsePyprojectToml, parseSetupPy } from '../parsers';
6
+
7
+ describe('parsePyprojectToml', () => {
8
+ describe('project name parsing', () => {
9
+ it('should parse project name with double quotes', () => {
10
+ const content = `
11
+ [project]
12
+ name = "my-package"
13
+ version = "1.0.0"
14
+ `;
15
+ const result = parsePyprojectToml(content);
16
+ expect(result.has('my-package')).toBe(true);
17
+ expect(result.has('my_package')).toBe(true);
18
+ });
19
+
20
+ it('should parse project name with single quotes', () => {
21
+ const content = `
22
+ [project]
23
+ name = 'my-package'
24
+ version = '1.0.0'
25
+ `;
26
+ const result = parsePyprojectToml(content);
27
+ expect(result.has('my-package')).toBe(true);
28
+ expect(result.has('my_package')).toBe(true);
29
+ });
30
+
31
+ it('should handle name without hyphens', () => {
32
+ const content = `
33
+ [project]
34
+ name = "mypackage"
35
+ `;
36
+ const result = parsePyprojectToml(content);
37
+ expect(result.has('mypackage')).toBe(true);
38
+ // underscore variant is same as original
39
+ expect(result.size).toBe(1);
40
+ });
41
+
42
+ it('should handle name with multiple hyphens', () => {
43
+ const content = `
44
+ [project]
45
+ name = "my-awesome-package"
46
+ `;
47
+ const result = parsePyprojectToml(content);
48
+ expect(result.has('my-awesome-package')).toBe(true);
49
+ expect(result.has('my_awesome_package')).toBe(true);
50
+ });
51
+
52
+ it('should handle whitespace around equals sign', () => {
53
+ const content = `
54
+ [project]
55
+ name="my-package"
56
+ `;
57
+ const result = parsePyprojectToml(content);
58
+ expect(result.has('my-package')).toBe(true);
59
+ });
60
+
61
+ it('should handle extra whitespace', () => {
62
+ const content = `
63
+ [project]
64
+ name = "my-package"
65
+ `;
66
+ const result = parsePyprojectToml(content);
67
+ expect(result.has('my-package')).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe('packages array parsing', () => {
72
+ it('should parse packages array with double quotes', () => {
73
+ const content = `
74
+ [tool.setuptools]
75
+ packages = ["pkg1", "pkg2", "pkg3"]
76
+ `;
77
+ const result = parsePyprojectToml(content);
78
+ expect(result.has('pkg1')).toBe(true);
79
+ expect(result.has('pkg2')).toBe(true);
80
+ expect(result.has('pkg3')).toBe(true);
81
+ });
82
+
83
+ it('should parse packages array with single quotes', () => {
84
+ const content = `
85
+ [tool.setuptools]
86
+ packages = ['pkg1', 'pkg2']
87
+ `;
88
+ const result = parsePyprojectToml(content);
89
+ expect(result.has('pkg1')).toBe(true);
90
+ expect(result.has('pkg2')).toBe(true);
91
+ });
92
+
93
+ it('should parse packages array with mixed quotes', () => {
94
+ const content = `
95
+ packages = ["pkg1", 'pkg2']
96
+ `;
97
+ const result = parsePyprojectToml(content);
98
+ expect(result.has('pkg1')).toBe(true);
99
+ expect(result.has('pkg2')).toBe(true);
100
+ });
101
+
102
+ it('should parse multiline packages array', () => {
103
+ const content = `
104
+ packages = [
105
+ "pkg1",
106
+ "pkg2",
107
+ "pkg3"
108
+ ]
109
+ `;
110
+ const result = parsePyprojectToml(content);
111
+ expect(result.has('pkg1')).toBe(true);
112
+ expect(result.has('pkg2')).toBe(true);
113
+ expect(result.has('pkg3')).toBe(true);
114
+ });
115
+
116
+ it('should parse single package in array', () => {
117
+ const content = `
118
+ packages = ["single_pkg"]
119
+ `;
120
+ const result = parsePyprojectToml(content);
121
+ expect(result.has('single_pkg')).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe('combined parsing', () => {
126
+ it('should parse both project name and packages array', () => {
127
+ const content = `
128
+ [project]
129
+ name = "main-package"
130
+ version = "1.0.0"
131
+
132
+ [tool.setuptools]
133
+ packages = ["subpkg1", "subpkg2"]
134
+ `;
135
+ const result = parsePyprojectToml(content);
136
+ expect(result.has('main-package')).toBe(true);
137
+ expect(result.has('main_package')).toBe(true);
138
+ expect(result.has('subpkg1')).toBe(true);
139
+ expect(result.has('subpkg2')).toBe(true);
140
+ });
141
+
142
+ it('should handle real-world pyproject.toml', () => {
143
+ const content = `
144
+ [build-system]
145
+ requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5"]
146
+ build-backend = "hatchling.build"
147
+
148
+ [project]
149
+ name = "jupyterlab_vscode_icons_extension"
150
+ version = "1.1.9"
151
+ description = "VSCode-style file icons for JupyterLab"
152
+ readme = "README.md"
153
+ license = { file = "LICENSE" }
154
+ requires-python = ">=3.8"
155
+ classifiers = [
156
+ "Framework :: Jupyter",
157
+ "Framework :: Jupyter :: JupyterLab",
158
+ "Framework :: Jupyter :: JupyterLab :: 4",
159
+ "License :: OSI Approved :: BSD License",
160
+ "Programming Language :: Python",
161
+ "Programming Language :: Python :: 3",
162
+ ]
163
+
164
+ [tool.hatch.build.targets.wheel.shared-data]
165
+ "jupyterlab_vscode_icons_extension/labextension" = "share/jupyter/labextensions/jupyterlab_vscode_icons_extension"
166
+ `;
167
+ const result = parsePyprojectToml(content);
168
+ expect(result.has('jupyterlab_vscode_icons_extension')).toBe(true);
169
+ });
170
+ });
171
+
172
+ describe('edge cases', () => {
173
+ it('should return empty set for empty content', () => {
174
+ const result = parsePyprojectToml('');
175
+ expect(result.size).toBe(0);
176
+ });
177
+
178
+ it('should return empty set for content without project section', () => {
179
+ const content = `
180
+ [build-system]
181
+ requires = ["hatchling"]
182
+ `;
183
+ const result = parsePyprojectToml(content);
184
+ expect(result.size).toBe(0);
185
+ });
186
+
187
+ it('should not match name outside [project] section', () => {
188
+ const content = `
189
+ [other-section]
190
+ name = "wrong-package"
191
+
192
+ [project]
193
+ version = "1.0.0"
194
+ `;
195
+ const result = parsePyprojectToml(content);
196
+ expect(result.has('wrong-package')).toBe(false);
197
+ });
198
+ });
199
+ });
200
+
201
+ describe('parseSetupPy', () => {
202
+ describe('name parsing', () => {
203
+ it('should parse name with double quotes', () => {
204
+ const content = `
205
+ from setuptools import setup
206
+
207
+ setup(
208
+ name="my-package",
209
+ version="1.0.0",
210
+ )
211
+ `;
212
+ const result = parseSetupPy(content);
213
+ expect(result.has('my-package')).toBe(true);
214
+ expect(result.has('my_package')).toBe(true);
215
+ });
216
+
217
+ it('should parse name with single quotes', () => {
218
+ const content = `
219
+ setup(
220
+ name='my-package',
221
+ )
222
+ `;
223
+ const result = parseSetupPy(content);
224
+ expect(result.has('my-package')).toBe(true);
225
+ });
226
+
227
+ it('should handle name without hyphens', () => {
228
+ const content = `
229
+ setup(name="mypackage")
230
+ `;
231
+ const result = parseSetupPy(content);
232
+ expect(result.has('mypackage')).toBe(true);
233
+ });
234
+ });
235
+
236
+ describe('packages array parsing', () => {
237
+ it('should parse packages array inline', () => {
238
+ const content = `
239
+ setup(
240
+ packages=["pkg1", "pkg2", "pkg3"],
241
+ )
242
+ `;
243
+ const result = parseSetupPy(content);
244
+ expect(result.has('pkg1')).toBe(true);
245
+ expect(result.has('pkg2')).toBe(true);
246
+ expect(result.has('pkg3')).toBe(true);
247
+ });
248
+
249
+ it('should parse packages array multiline', () => {
250
+ const content = `
251
+ setup(
252
+ packages=[
253
+ "pkg1",
254
+ "pkg2",
255
+ ],
256
+ )
257
+ `;
258
+ const result = parseSetupPy(content);
259
+ expect(result.has('pkg1')).toBe(true);
260
+ expect(result.has('pkg2')).toBe(true);
261
+ });
262
+
263
+ it('should parse packages with single quotes', () => {
264
+ const content = `
265
+ packages=['pkg1', 'pkg2']
266
+ `;
267
+ const result = parseSetupPy(content);
268
+ expect(result.has('pkg1')).toBe(true);
269
+ expect(result.has('pkg2')).toBe(true);
270
+ });
271
+ });
272
+
273
+ describe('combined parsing', () => {
274
+ it('should parse both name and packages', () => {
275
+ const content = `
276
+ from setuptools import setup
277
+
278
+ setup(
279
+ name="main-package",
280
+ version="1.0.0",
281
+ packages=["subpkg1", "subpkg2"],
282
+ )
283
+ `;
284
+ const result = parseSetupPy(content);
285
+ expect(result.has('main-package')).toBe(true);
286
+ expect(result.has('main_package')).toBe(true);
287
+ expect(result.has('subpkg1')).toBe(true);
288
+ expect(result.has('subpkg2')).toBe(true);
289
+ });
290
+
291
+ it('should handle real-world setup.py', () => {
292
+ const content = `
293
+ #!/usr/bin/env python
294
+ from setuptools import setup, find_packages
295
+
296
+ setup(
297
+ name="my-awesome-lib",
298
+ version="2.0.0",
299
+ author="Developer",
300
+ author_email="dev@example.com",
301
+ description="An awesome library",
302
+ packages=find_packages(),
303
+ python_requires=">=3.8",
304
+ install_requires=[
305
+ "numpy>=1.0",
306
+ "pandas>=1.0",
307
+ ],
308
+ )
309
+ `;
310
+ const result = parseSetupPy(content);
311
+ expect(result.has('my-awesome-lib')).toBe(true);
312
+ expect(result.has('my_awesome_lib')).toBe(true);
313
+ });
314
+ });
315
+
316
+ describe('edge cases', () => {
317
+ it('should return empty set for empty content', () => {
318
+ const result = parseSetupPy('');
319
+ expect(result.size).toBe(0);
320
+ });
321
+
322
+ it('should return empty set for content with find_packages() only', () => {
323
+ const content = `
324
+ setup(
325
+ packages=find_packages(),
326
+ )
327
+ `;
328
+ const result = parseSetupPy(content);
329
+ // find_packages() is dynamic, not a literal array
330
+ expect(result.size).toBe(0);
331
+ });
332
+
333
+ it('should handle whitespace variations', () => {
334
+ const content = `
335
+ setup(name = "my-pkg",packages = ["pkg1"])
336
+ `;
337
+ const result = parseSetupPy(content);
338
+ expect(result.has('my-pkg')).toBe(true);
339
+ expect(result.has('pkg1')).toBe(true);
340
+ });
341
+ });
342
+ });
package/src/index.ts CHANGED
@@ -2,10 +2,12 @@ import {
2
2
  JupyterFrontEnd,
3
3
  JupyterFrontEndPlugin
4
4
  } from '@jupyterlab/application';
5
+ import { PageConfig, URLExt } from '@jupyterlab/coreutils';
5
6
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
6
7
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
7
8
  import { LabIcon } from '@jupyterlab/ui-components';
8
9
  import { getIconSVG } from './icons';
10
+ import { parsePyprojectToml, parseSetupPy } from './parsers';
9
11
 
10
12
  const PLUGIN_ID = 'jupyterlab_vscode_icons_extension:plugin';
11
13
 
@@ -17,6 +19,7 @@ interface IIconSettings {
17
19
  enableConfigIcons: boolean;
18
20
  enableDocIcons: boolean;
19
21
  enableImageIcons: boolean;
22
+ enableExecutableIcons: boolean;
20
23
  }
21
24
 
22
25
  /**
@@ -156,7 +159,7 @@ const fileTypeConfigs: IFileTypeConfig[] = [
156
159
  iconName: 'file-type-perl',
157
160
  group: 'enableLanguageIcons'
158
161
  },
159
- // Shell scripts (.sh, .bash, .zsh) and batch files (.bat, .cmd) use custom icons with black backgrounds
162
+ // Shell scripts (.sh, .bash, .zsh, .fish, .csh, .nu) and batch files (.bat, .cmd) use custom icons with black backgrounds
160
163
  // Registered separately below with custom SVGs
161
164
  {
162
165
  extensions: ['.ps1'],
@@ -494,6 +497,13 @@ const plugin: JupyterFrontEndPlugin<void> = {
494
497
  const pythonPackageSvg =
495
498
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="ppa" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0" stop-color="#387eb8"/><stop offset="1" stop-color="#366994"/></linearGradient><linearGradient id="ppb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0" stop-color="#ffe052"/><stop offset="1" stop-color="#ffc331"/></linearGradient></defs><g transform="scale(-1,1) translate(-32,0)"><path d="M27.4,5.5H18.1L16,9.7H4.3V26.5H29.5V5.5Zm0,4.2H19.2l1.1-2.1h7.1Z" fill="#58af7b"/><path d="M20.9,11c-5.1,0-4.8,2.2-4.8,2.2v2.3H21v.7H14.2S11,15.8,11,21s2.9,5,2.9,5h1.7V23.6a2.7,2.7,0,0,1,2.8-2.9h4.8a2.6,2.6,0,0,0,2.7-2.6V13.7S26.2,11,20.9,11Zm-2.7,1.5a.9.9,0,1,1-.8.9.9.9,0,0,1,.8-.9Z" fill="url(#ppa)"/><path d="M21.1,31c5.1,0,4.8-2.2,4.8-2.2V26.5H21v-.7h6.8S31,26.1,31,21s-2.9-5-2.9-5h-1.7v2.4a2.7,2.7,0,0,1-2.8,2.9H18.8a2.6,2.6,0,0,0-2.7,2.6v4.4S15.7,31,21,31Zm2.7-1.5a.9.9,0,1,1,.8-.9.9.9,0,0,1-.8.9Z" fill="url(#ppb)"/></g></svg>';
496
499
  const pythonPackageDataUri = `data:image/svg+xml;base64,${btoa(pythonPackageSvg)}`;
500
+ const venvSvg =
501
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><g transform="scale(-1,1) translate(-32,0)"><path d="M27.4,5.5H18.1L16,9.7H4.3V26.5H29.5V5.5Zm0,4.2H19.2l1.1-2.1h7.1Z" fill="#9575cd"/><g transform="translate(22,22) scale(1.25)" fill="#bababa"><path d="M-1.2,-6 L1.2,-6 L1.5,-4.5 L2.8,-4 L4,-5 L5.5,-3.5 L4.5,-2.3 L5,-1 L6.5,-0.8 L6.5,1.2 L5,1.5 L4.5,2.8 L5.5,4 L4,5.5 L2.8,4.5 L1.5,5 L1.2,6.5 L-1.2,6.5 L-1.5,5 L-2.8,4.5 L-4,5.5 L-5.5,4 L-4.5,2.8 L-5,1.5 L-6.5,1.2 L-6.5,-0.8 L-5,-1 L-4.5,-2.3 L-5.5,-3.5 L-4,-5 L-2.8,-4 L-1.5,-4.5 Z"/><circle cx="0" cy="0" r="2.5" fill="#9575cd"/></g></g></svg>';
502
+ const venvDataUri = `data:image/svg+xml;base64,${btoa(venvSvg)}`;
503
+ // Executable file icon - JupyterLab standard file icon with play triangle overlay
504
+ const executableSvg =
505
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22"><path fill="#616161" d="m19.3 8.2-5.5-5.5c-.3-.3-.7-.5-1.2-.5H3.9c-.8.1-1.6.9-1.6 1.8v14.1c0 .9.7 1.6 1.6 1.6h14.2c.9 0 1.6-.7 1.6-1.6V9.4c.1-.5-.1-.9-.4-1.2m-5.8-3.3 3.4 3.6h-3.4zm3.9 12.7H4.7c-.1 0-.2 0-.2-.2V4.7c0-.2.1-.3.2-.3h7.2v4.4s0 .8.3 1.1 1.1.3 1.1.3h4.3v7.2s-.1.2-.2.2"/><path fill="#66bb6a" d="M12,12 L20,16 L12,20 Z"/></svg>';
506
+ const executableDataUri = `data:image/svg+xml;base64,${btoa(executableSvg)}`;
497
507
 
498
508
  // Inject CSS that overrides icons for .py and .md files
499
509
  // Note: Jupytext marks .py and .md files as type="notebook", so we need to
@@ -698,6 +708,38 @@ const plugin: JupyterFrontEndPlugin<void> = {
698
708
  background-repeat: no-repeat;
699
709
  background-position: center;
700
710
  }
711
+
712
+ /* Override venv folder icons (.venv, venv, .env, env) */
713
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon svg,
714
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon img {
715
+ display: none !important;
716
+ }
717
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon::before {
718
+ content: '';
719
+ display: inline-block;
720
+ width: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
721
+ height: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
722
+ background-image: url('${venvDataUri}');
723
+ background-size: contain;
724
+ background-repeat: no-repeat;
725
+ background-position: center;
726
+ }
727
+
728
+ /* Override executable file icons */
729
+ .jp-DirListing-item[data-executable] .jp-DirListing-itemIcon svg,
730
+ .jp-DirListing-item[data-executable] .jp-DirListing-itemIcon img {
731
+ display: none !important;
732
+ }
733
+ .jp-DirListing-item[data-executable] .jp-DirListing-itemIcon::before {
734
+ content: '';
735
+ display: inline-block;
736
+ width: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
737
+ height: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
738
+ background-image: url('${executableDataUri}');
739
+ background-size: contain;
740
+ background-repeat: no-repeat;
741
+ background-position: center;
742
+ }
701
743
  `;
702
744
 
703
745
  // Add CSS to make JavaScript and .env icons less bright
@@ -721,78 +763,116 @@ const plugin: JupyterFrontEndPlugin<void> = {
721
763
  }
722
764
  `;
723
765
 
724
- // Cache for Python package folder checks
725
- const pythonPackageCache: Map<string, boolean> = new Map();
726
-
727
- // Folders to exclude from Python package checking
728
- const excludedFolderPatterns = [
729
- /^\.git$/,
730
- /^\.hg$/,
731
- /^\.svn$/,
732
- /^\.venv$/,
733
- /^venv$/,
734
- /^\.env$/,
735
- /^env$/,
736
- /^node_modules$/,
737
- /^__pycache__$/,
738
- /^\.ipynb_checkpoints$/,
739
- /^\.pytest_cache$/,
740
- /^\.mypy_cache$/,
741
- /^\.ruff_cache$/,
742
- /^\.tox$/,
743
- /^\.nox$/,
744
- /^\.coverage$/,
745
- /^htmlcov$/,
746
- /^dist$/,
747
- /^build$/,
748
- /^\.eggs$/,
749
- /\.egg-info$/,
750
- /\.dist-info$/
751
- ];
752
-
753
- const isExcludedFolder = (name: string): boolean => {
754
- return excludedFolderPatterns.some(pattern => pattern.test(name));
755
- };
766
+ // Cache for Python package detection (per directory)
767
+ let pythonPackagesCache: { path: string; packages: Set<string> } | null =
768
+ null;
769
+
770
+ // Detect Python packages by parsing pyproject.toml or setup.py
771
+ const detectPythonPackages = async (): Promise<Set<string>> => {
772
+ const currentPath = defaultFileBrowser?.model.path || '';
773
+
774
+ // Return cached result if same directory
775
+ if (pythonPackagesCache?.path === currentPath) {
776
+ return pythonPackagesCache.packages;
777
+ }
778
+
779
+ const packages = new Set<string>();
780
+
781
+ try {
782
+ const contents = await app.serviceManager.contents.get(currentPath, {
783
+ content: true
784
+ });
785
+ const files = contents.content || [];
756
786
 
757
- // Check if a folder is a Python package (contains __init__.py)
758
- // Use the file browser's model to get the correct path
759
- const checkPythonPackage = async (
760
- folderName: string
761
- ): Promise<boolean> => {
762
- // Skip excluded folders
763
- if (isExcludedFolder(folderName)) {
764
- return false;
787
+ // Check for pyproject.toml
788
+ const hasPyproject = files.some(
789
+ (f: any) => f.name === 'pyproject.toml'
790
+ );
791
+ if (hasPyproject) {
792
+ const pyprojectPath = currentPath
793
+ ? `${currentPath}/pyproject.toml`
794
+ : 'pyproject.toml';
795
+ const file = await app.serviceManager.contents.get(pyprojectPath);
796
+ const content = file.content as string;
797
+ const parsed = parsePyprojectToml(content);
798
+ parsed.forEach(p => packages.add(p));
799
+ }
800
+
801
+ // Check for setup.py
802
+ const hasSetupPy = files.some((f: any) => f.name === 'setup.py');
803
+ if (hasSetupPy) {
804
+ const setupPath = currentPath
805
+ ? `${currentPath}/setup.py`
806
+ : 'setup.py';
807
+ const file = await app.serviceManager.contents.get(setupPath);
808
+ const content = file.content as string;
809
+ const parsed = parseSetupPy(content);
810
+ parsed.forEach(p => packages.add(p));
811
+ }
812
+ } catch {
813
+ // Ignore errors - no packages detected
765
814
  }
766
815
 
767
- // Get the current path from the file browser model
768
- if (!defaultFileBrowser) {
769
- return false;
816
+ pythonPackagesCache = { path: currentPath, packages };
817
+ return packages;
818
+ };
819
+
820
+ // Cache for executable file detection (per directory)
821
+ let executablesCache: { path: string; executables: Set<string> } | null =
822
+ null;
823
+
824
+ // Detect executable files via server API
825
+ const detectExecutables = async (): Promise<Set<string>> => {
826
+ if (!settings.enableExecutableIcons) {
827
+ return new Set<string>();
770
828
  }
771
- const currentPath = defaultFileBrowser.model.path;
772
- const fullPath = currentPath
773
- ? `${currentPath}/${folderName}`
774
- : folderName;
775
829
 
776
- if (pythonPackageCache.has(fullPath)) {
777
- return pythonPackageCache.get(fullPath)!;
830
+ const currentPath = defaultFileBrowser?.model.path || '';
831
+
832
+ // Return cached result if same directory
833
+ if (executablesCache?.path === currentPath) {
834
+ return executablesCache.executables;
778
835
  }
836
+
837
+ const executables = new Set<string>();
838
+
779
839
  try {
780
- // Use JupyterLab's contents manager with the correct path from file browser
781
- const contents = app.serviceManager.contents;
782
- const model = await contents.get(fullPath, { content: true });
783
- const hasInit =
784
- model.content?.some((item: any) => item.name === '__init__.py') ||
785
- false;
786
- pythonPackageCache.set(fullPath, hasInit);
787
- return hasInit;
840
+ const baseUrl = PageConfig.getBaseUrl();
841
+ const apiUrl = URLExt.join(
842
+ baseUrl,
843
+ 'vscode-icons',
844
+ 'executables'
845
+ );
846
+ const response = await fetch(
847
+ `${apiUrl}?path=${encodeURIComponent(currentPath)}`
848
+ );
849
+ if (response.ok) {
850
+ const files: string[] = await response.json();
851
+ files.forEach(f => executables.add(f));
852
+ }
788
853
  } catch {
789
- pythonPackageCache.set(fullPath, false);
790
- return false;
854
+ // Ignore errors - no executables detected
791
855
  }
856
+
857
+ executablesCache = { path: currentPath, executables };
858
+ return executables;
792
859
  };
793
860
 
861
+ // Invalidate cache on directory change
862
+ if (defaultFileBrowser) {
863
+ defaultFileBrowser.model.pathChanged.connect(() => {
864
+ pythonPackagesCache = null;
865
+ executablesCache = null;
866
+ });
867
+ }
868
+
794
869
  // Add a MutationObserver to mark special files in the file browser
795
- const markSpecialFiles = () => {
870
+ const markSpecialFiles = async () => {
871
+ // Get Python packages for current directory (cached)
872
+ const pythonPackages = await detectPythonPackages();
873
+ // Get executable files for current directory (cached, only if setting enabled)
874
+ const executables = await detectExecutables();
875
+
796
876
  // Process ALL items - clear wrong attributes and set correct ones
797
877
  const allItems = document.querySelectorAll('.jp-DirListing-item');
798
878
  allItems.forEach(item => {
@@ -885,29 +965,49 @@ const plugin: JupyterFrontEndPlugin<void> = {
885
965
  item.setAttribute('data-pytest', 'true');
886
966
  }
887
967
 
968
+ // Mark executable files if setting is enabled (uses server API for +x detection)
969
+ item.removeAttribute('data-executable');
970
+ if (settings.enableExecutableIcons && executables.has(name)) {
971
+ item.setAttribute('data-executable', 'true');
972
+ }
973
+
888
974
  // Check if this is a directory (folder)
889
975
  const isDir =
890
976
  fileType === 'directory' ||
891
977
  item.classList.contains('jp-DirListing-directory');
892
978
  if (isDir) {
893
- // Check if this folder is a Python package (async)
894
- // Pass just the folder name - checkPythonPackage gets current path from file browser
895
- checkPythonPackage(name).then(isPythonPackage => {
896
- if (isPythonPackage) {
979
+ // Check if folder is a venv folder (.venv, venv, .env, env)
980
+ const venvNames = ['.venv', 'venv', '.env', 'env'];
981
+ if (venvNames.includes(nameLower)) {
982
+ item.setAttribute('data-venv-folder', 'true');
983
+ item.removeAttribute('data-python-package');
984
+ } else {
985
+ item.removeAttribute('data-venv-folder');
986
+ // Check if folder name matches a detected Python package
987
+ if (pythonPackages.has(name)) {
897
988
  item.setAttribute('data-python-package', 'true');
898
989
  } else {
899
990
  item.removeAttribute('data-python-package');
900
991
  }
901
- });
992
+ }
902
993
  } else {
903
994
  item.removeAttribute('data-python-package');
995
+ item.removeAttribute('data-venv-folder');
904
996
  }
905
997
  });
906
998
  };
907
999
 
908
- // Watch for changes in the file browser
1000
+ // Debounce timeout for MutationObserver
1001
+ let markSpecialFilesTimeout: ReturnType<typeof setTimeout> | null = null;
1002
+
1003
+ // Watch for changes in the file browser (debounced)
909
1004
  const observer = new MutationObserver(() => {
910
- markSpecialFiles();
1005
+ if (markSpecialFilesTimeout) {
1006
+ clearTimeout(markSpecialFilesTimeout);
1007
+ }
1008
+ markSpecialFilesTimeout = setTimeout(() => {
1009
+ markSpecialFiles();
1010
+ }, 100);
911
1011
  });
912
1012
 
913
1013
  // Start observing when the file browser is ready
@@ -945,7 +1045,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
945
1045
  enableDataIcons: true,
946
1046
  enableConfigIcons: true,
947
1047
  enableDocIcons: true,
948
- enableImageIcons: true
1048
+ enableImageIcons: true,
1049
+ enableExecutableIcons: false
949
1050
  };
950
1051
 
951
1052
  // Function to register file types based on settings
@@ -1124,7 +1225,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
1124
1225
  docRegistry.addFileType({
1125
1226
  name: 'vscode-shell',
1126
1227
  displayName: 'Shell Script',
1127
- extensions: ['.sh', '.bash', '.zsh'],
1228
+ extensions: ['.sh', '.bash', '.zsh', '.fish', '.csh', '.nu'],
1128
1229
  fileFormat: 'text',
1129
1230
  contentType: 'file',
1130
1231
  icon: shellIcon
package/src/parsers.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Python package configuration file parsers
3
+ * Extracts package names from pyproject.toml and setup.py files
4
+ */
5
+
6
+ /**
7
+ * Parse pyproject.toml content to extract Python package names
8
+ * @param content - The content of pyproject.toml file
9
+ * @returns Set of package names found in the file
10
+ */
11
+ export function parsePyprojectToml(content: string): Set<string> {
12
+ const packages = new Set<string>();
13
+
14
+ // Parse [project] name = "package_name"
15
+ const nameMatch = content.match(
16
+ /\[project\][^[]*name\s*=\s*["']([^"']+)["']/s
17
+ );
18
+ if (nameMatch) {
19
+ packages.add(nameMatch[1]);
20
+ // Also add underscore variant (package-name -> package_name)
21
+ packages.add(nameMatch[1].replace(/-/g, '_'));
22
+ }
23
+
24
+ // Parse packages = ["pkg1", "pkg2"]
25
+ const packagesMatch = content.match(/packages\s*=\s*\[([^\]]+)\]/);
26
+ if (packagesMatch) {
27
+ const pkgList = packagesMatch[1].match(/["']([^"']+)["']/g);
28
+ pkgList?.forEach(p => packages.add(p.replace(/["']/g, '')));
29
+ }
30
+
31
+ return packages;
32
+ }
33
+
34
+ /**
35
+ * Parse setup.py content to extract Python package names
36
+ * @param content - The content of setup.py file
37
+ * @returns Set of package names found in the file
38
+ */
39
+ export function parseSetupPy(content: string): Set<string> {
40
+ const packages = new Set<string>();
41
+
42
+ // Parse packages=["pkg1", "pkg2"]
43
+ const packagesMatch = content.match(/packages\s*=\s*\[([^\]]+)\]/);
44
+ if (packagesMatch) {
45
+ const pkgList = packagesMatch[1].match(/["']([^"']+)["']/g);
46
+ pkgList?.forEach(p => packages.add(p.replace(/["']/g, '')));
47
+ }
48
+
49
+ // Parse name="package_name"
50
+ const nameMatch = content.match(/name\s*=\s*["']([^"']+)["']/);
51
+ if (nameMatch) {
52
+ packages.add(nameMatch[1]);
53
+ packages.add(nameMatch[1].replace(/-/g, '_'));
54
+ }
55
+
56
+ return packages;
57
+ }