jupyterlab_vscode_icons_extension 1.1.6 → 1.1.14

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
@@ -2,6 +2,7 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry';
2
2
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
3
3
  import { LabIcon } from '@jupyterlab/ui-components';
4
4
  import { getIconSVG } from './icons';
5
+ import { parsePyprojectToml, parseSetupPy } from './parsers';
5
6
  const PLUGIN_ID = 'jupyterlab_vscode_icons_extension:plugin';
6
7
  /**
7
8
  * Create a LabIcon from vscode-icons SVG data
@@ -369,10 +370,10 @@ const plugin = {
369
370
  const wordIcon = createLabIcon('file-type-word');
370
371
  const excelIcon = createLabIcon('file-type-excel');
371
372
  const powerpointIcon = createLabIcon('file-type-powerpoint');
372
- // Custom Markdown icon (from markdown.svg - purple M with arrow, darker #7a2491)
373
+ // Custom Markdown icon (from markdown.svg - purple M with arrow, #8a2ea5)
373
374
  const markdownSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 309 327">
374
- <path fill="#7a2491" opacity="1" stroke="none" d="m 138.68393,230.48651 c 36.58836,3.1e-4 72.68422,3.1e-4 108.78008,3.1e-4 0.13988,0.49669 0.0821,0.12537 -3.34406,3.81472 -27.34165,24.16766 -54.43119,49.41695 -81.72391,73.62893 -2.65146,2.35216 -4.5582,3.21609 -7.64686,0.37229 -26.89754,-24.76539 -75.191307,-68.40096 -80.889724,-74.12425 -0.744118,-0.74735 -1.274501,-1.57204 -2.95867,-3.69233 23.309236,0 45.299954,0 67.783144,3.3e-4 z"/>
375
- <path fill="#7a2491" d="m 61.156397,14.443673 h 69.176263 q 14.81059,56.661581 23.29958,97.452667 l 5.96036,-27.150338 q 3.61233,-15.870486 7.76652,-30.954008 l 10.6564,-39.348321 H 248.6367 L 276.09047,189.5437 H 221.90541 L 207.27544,69.137838 173.50009,189.5437 H 136.47364 L 101.07273,68.875516 86.984609,189.5437 H 35.147571 Z"/>
375
+ <path fill="#8a2ea5" opacity="1" stroke="none" d="m 138.68393,230.48651 c 36.58836,3.1e-4 72.68422,3.1e-4 108.78008,3.1e-4 0.13988,0.49669 0.0821,0.12537 -3.34406,3.81472 -27.34165,24.16766 -54.43119,49.41695 -81.72391,73.62893 -2.65146,2.35216 -4.5582,3.21609 -7.64686,0.37229 -26.89754,-24.76539 -75.191307,-68.40096 -80.889724,-74.12425 -0.744118,-0.74735 -1.274501,-1.57204 -2.95867,-3.69233 23.309236,0 45.299954,0 67.783144,3.3e-4 z"/>
376
+ <path fill="#8a2ea5" d="m 61.156397,14.443673 h 69.176263 q 14.81059,56.661581 23.29958,97.452667 l 5.96036,-27.150338 q 3.61233,-15.870486 7.76652,-30.954008 l 10.6564,-39.348321 H 248.6367 L 276.09047,189.5437 H 221.90541 L 207.27544,69.137838 173.50009,189.5437 H 136.47364 L 101.07273,68.875516 86.984609,189.5437 H 35.147571 Z"/>
376
377
  </svg>`;
377
378
  // Custom PDF icon (document with folded corner and red PDF banner)
378
379
  const pdfSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 16">
@@ -406,9 +407,10 @@ const plugin = {
406
407
  <path fill="url(#py-b)" d="M55,0C29,0,29,10,29,12v13h27v4H19C11,29,0,34,0,55c0,20,8,27,16,27h9V69c0-6,3-16,16-16h26c4,0,15-2,15-14V14C82,11,82,0,55,0zM40,8c3,0,5,2,5,5s-2,5-5,5-5-2-5-5S37,8,40,8z"/>
407
408
  <path fill="url(#py-y)" d="M55,110c26,0,26-10,26-12V85H54v-4h37c8,0,18-5,18-26 0-23-11-27-16-27h-9v13c0,6-3,16-16,16H42c-4,0-15,2-15,14v24c0,3,0,14,28,14zM70,101c-3,0-5-2-5-5s2-5,5-5 5,2,5,5S73,101,70,101z"/>
408
409
  </svg>`;
409
- // Custom README icon (info icon - centered, color #912bac from info2.svg)
410
+ // Custom README icon (info icon - purple background #912bac, gray "i" #bdbdbd)
410
411
  const readmeSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
411
- <path fill="#912bac" d="m 9.5247234,11.42511 v 76.943619 l 2.1166666,2.116667 h 77.809717 l 2.116667,-2.116667 v -76.94362 l -2.116667,-2.1166662 -77.809717,0 z m 36.2115596,9.141612 h 11.01003 l 2.116667,2.116667 0,7.619586 -2.116667,2.116667 h -11.01003 l -2.116667,-2.116667 v -7.619586 z m -6.168542,19.478681 h 23.084596 l 2.116667,2.116667 v 5.700733 l -2.116597,2.09951 -3.175139,-0.02574 -2.116597,2.09951 V 67.32187 l 2.116667,2.116667 h 4.211629 l 2.116667,2.116667 v 5.567219 L 63.688967,79.23909 H 38.509408 l -2.116667,-2.116667 v -6.096386 l 2.116667,-2.116667 h 4.968955 L 45.59503,66.792703 V 52.096294 l -2.116667,-2.116667 h -3.910622 l -2.116667,-2.116667 0,-5.70089 z"/>
412
+ <rect x="9.5" y="9.3" width="82" height="81.2" rx="4" fill="#912bac"/>
413
+ <path fill="#bdbdbd" d="m 45.736283,20.566722 h 11.01003 l 2.116667,2.116667 0,7.619586 -2.116667,2.116667 h -11.01003 l -2.116667,-2.116667 v -7.619586 z m -6.168542,19.478681 h 23.084596 l 2.116667,2.116667 v 5.700733 l -2.116597,2.09951 -3.175139,-0.02574 -2.116597,2.09951 V 67.32187 l 2.116667,2.116667 h 4.211629 l 2.116667,2.116667 v 5.567219 L 63.688967,79.23909 H 38.509408 l -2.116667,-2.116667 v -6.096386 l 2.116667,-2.116667 h 4.968955 L 45.59503,66.792703 V 52.096294 l -2.116667,-2.116667 h -3.910622 l -2.116667,-2.116667 0,-5.70089 z"/>
412
414
  </svg>`;
413
415
  // Get SVG content from VSCode icons
414
416
  const claudeSvg = claudeIcon.svgstr;
@@ -441,6 +443,8 @@ const plugin = {
441
443
  const pytestDataUri = `data:image/svg+xml;base64,${btoa(pytestSvg)}`;
442
444
  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>';
443
445
  const pythonPackageDataUri = `data:image/svg+xml;base64,${btoa(pythonPackageSvg)}`;
446
+ 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>';
447
+ const venvDataUri = `data:image/svg+xml;base64,${btoa(venvSvg)}`;
444
448
  // Inject CSS that overrides icons for .py and .md files
445
449
  // Note: Jupytext marks .py and .md files as type="notebook", so we need to
446
450
  // use JavaScript to detect and mark these files for CSS targeting
@@ -644,6 +648,22 @@ const plugin = {
644
648
  background-repeat: no-repeat;
645
649
  background-position: center;
646
650
  }
651
+
652
+ /* Override venv folder icons (.venv, venv, .env, env) */
653
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon svg,
654
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon img {
655
+ display: none !important;
656
+ }
657
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon::before {
658
+ content: '';
659
+ display: inline-block;
660
+ width: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
661
+ height: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
662
+ background-image: url('${venvDataUri}');
663
+ background-size: contain;
664
+ background-repeat: no-repeat;
665
+ background-position: center;
666
+ }
647
667
  `;
648
668
  // Add CSS to make JavaScript and .env icons less bright
649
669
  style.textContent += `
@@ -665,71 +685,60 @@ const plugin = {
665
685
  opacity: 55% !important;
666
686
  }
667
687
  `;
668
- // Cache for Python package folder checks
669
- const pythonPackageCache = new Map();
670
- // Folders to exclude from Python package checking
671
- const excludedFolderPatterns = [
672
- /^\.git$/,
673
- /^\.hg$/,
674
- /^\.svn$/,
675
- /^\.venv$/,
676
- /^venv$/,
677
- /^\.env$/,
678
- /^env$/,
679
- /^node_modules$/,
680
- /^__pycache__$/,
681
- /^\.ipynb_checkpoints$/,
682
- /^\.pytest_cache$/,
683
- /^\.mypy_cache$/,
684
- /^\.ruff_cache$/,
685
- /^\.tox$/,
686
- /^\.nox$/,
687
- /^\.coverage$/,
688
- /^htmlcov$/,
689
- /^dist$/,
690
- /^build$/,
691
- /^\.eggs$/,
692
- /\.egg-info$/,
693
- /\.dist-info$/
694
- ];
695
- const isExcludedFolder = (name) => {
696
- return excludedFolderPatterns.some(pattern => pattern.test(name));
697
- };
698
- // Check if a folder is a Python package (contains __init__.py)
699
- // Use the file browser's model to get the correct path
700
- const checkPythonPackage = async (folderName) => {
701
- var _a;
702
- // Skip excluded folders
703
- if (isExcludedFolder(folderName)) {
704
- return false;
705
- }
706
- // Get the current path from the file browser model
707
- if (!defaultFileBrowser) {
708
- return false;
709
- }
710
- const currentPath = defaultFileBrowser.model.path;
711
- const fullPath = currentPath
712
- ? `${currentPath}/${folderName}`
713
- : folderName;
714
- if (pythonPackageCache.has(fullPath)) {
715
- return pythonPackageCache.get(fullPath);
688
+ // Cache for Python package detection (per directory)
689
+ let pythonPackagesCache = null;
690
+ // Detect Python packages by parsing pyproject.toml or setup.py
691
+ const detectPythonPackages = async () => {
692
+ const currentPath = (defaultFileBrowser === null || defaultFileBrowser === void 0 ? void 0 : defaultFileBrowser.model.path) || '';
693
+ // Return cached result if same directory
694
+ if ((pythonPackagesCache === null || pythonPackagesCache === void 0 ? void 0 : pythonPackagesCache.path) === currentPath) {
695
+ return pythonPackagesCache.packages;
716
696
  }
697
+ const packages = new Set();
717
698
  try {
718
- // Use JupyterLab's contents manager with the correct path from file browser
719
- const contents = app.serviceManager.contents;
720
- const model = await contents.get(fullPath, { content: true });
721
- const hasInit = ((_a = model.content) === null || _a === void 0 ? void 0 : _a.some((item) => item.name === '__init__.py')) ||
722
- false;
723
- pythonPackageCache.set(fullPath, hasInit);
724
- return hasInit;
699
+ const contents = await app.serviceManager.contents.get(currentPath, {
700
+ content: true
701
+ });
702
+ const files = contents.content || [];
703
+ // Check for pyproject.toml
704
+ const hasPyproject = files.some((f) => f.name === 'pyproject.toml');
705
+ if (hasPyproject) {
706
+ const pyprojectPath = currentPath
707
+ ? `${currentPath}/pyproject.toml`
708
+ : 'pyproject.toml';
709
+ const file = await app.serviceManager.contents.get(pyprojectPath);
710
+ const content = file.content;
711
+ const parsed = parsePyprojectToml(content);
712
+ parsed.forEach(p => packages.add(p));
713
+ }
714
+ // Check for setup.py
715
+ const hasSetupPy = files.some((f) => f.name === 'setup.py');
716
+ if (hasSetupPy) {
717
+ const setupPath = currentPath
718
+ ? `${currentPath}/setup.py`
719
+ : 'setup.py';
720
+ const file = await app.serviceManager.contents.get(setupPath);
721
+ const content = file.content;
722
+ const parsed = parseSetupPy(content);
723
+ parsed.forEach(p => packages.add(p));
724
+ }
725
725
  }
726
- catch (_b) {
727
- pythonPackageCache.set(fullPath, false);
728
- return false;
726
+ catch (_a) {
727
+ // Ignore errors - no packages detected
729
728
  }
729
+ pythonPackagesCache = { path: currentPath, packages };
730
+ return packages;
730
731
  };
732
+ // Invalidate cache on directory change
733
+ if (defaultFileBrowser) {
734
+ defaultFileBrowser.model.pathChanged.connect(() => {
735
+ pythonPackagesCache = null;
736
+ });
737
+ }
731
738
  // Add a MutationObserver to mark special files in the file browser
732
- const markSpecialFiles = () => {
739
+ const markSpecialFiles = async () => {
740
+ // Get Python packages for current directory (cached)
741
+ const pythonPackages = await detectPythonPackages();
733
742
  // Process ALL items - clear wrong attributes and set correct ones
734
743
  const allItems = document.querySelectorAll('.jp-DirListing-item');
735
744
  allItems.forEach(item => {
@@ -812,25 +821,39 @@ const plugin = {
812
821
  const isDir = fileType === 'directory' ||
813
822
  item.classList.contains('jp-DirListing-directory');
814
823
  if (isDir) {
815
- // Check if this folder is a Python package (async)
816
- // Pass just the folder name - checkPythonPackage gets current path from file browser
817
- checkPythonPackage(name).then(isPythonPackage => {
818
- if (isPythonPackage) {
824
+ // Check if folder is a venv folder (.venv, venv, .env, env)
825
+ const venvNames = ['.venv', 'venv', '.env', 'env'];
826
+ if (venvNames.includes(nameLower)) {
827
+ item.setAttribute('data-venv-folder', 'true');
828
+ item.removeAttribute('data-python-package');
829
+ }
830
+ else {
831
+ item.removeAttribute('data-venv-folder');
832
+ // Check if folder name matches a detected Python package
833
+ if (pythonPackages.has(name)) {
819
834
  item.setAttribute('data-python-package', 'true');
820
835
  }
821
836
  else {
822
837
  item.removeAttribute('data-python-package');
823
838
  }
824
- });
839
+ }
825
840
  }
826
841
  else {
827
842
  item.removeAttribute('data-python-package');
843
+ item.removeAttribute('data-venv-folder');
828
844
  }
829
845
  });
830
846
  };
831
- // Watch for changes in the file browser
847
+ // Debounce timeout for MutationObserver
848
+ let markSpecialFilesTimeout = null;
849
+ // Watch for changes in the file browser (debounced)
832
850
  const observer = new MutationObserver(() => {
833
- markSpecialFiles();
851
+ if (markSpecialFilesTimeout) {
852
+ clearTimeout(markSpecialFilesTimeout);
853
+ }
854
+ markSpecialFilesTimeout = setTimeout(() => {
855
+ markSpecialFiles();
856
+ }, 100);
834
857
  });
835
858
  // Start observing when the file browser is ready
836
859
  setTimeout(() => {
@@ -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.6",
3
+ "version": "1.1.14",
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
@@ -6,6 +6,7 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry';
6
6
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
7
7
  import { LabIcon } from '@jupyterlab/ui-components';
8
8
  import { getIconSVG } from './icons';
9
+ import { parsePyprojectToml, parseSetupPy } from './parsers';
9
10
 
10
11
  const PLUGIN_ID = 'jupyterlab_vscode_icons_extension:plugin';
11
12
 
@@ -413,10 +414,10 @@ const plugin: JupyterFrontEndPlugin<void> = {
413
414
  const excelIcon = createLabIcon('file-type-excel');
414
415
  const powerpointIcon = createLabIcon('file-type-powerpoint');
415
416
 
416
- // Custom Markdown icon (from markdown.svg - purple M with arrow, darker #7a2491)
417
+ // Custom Markdown icon (from markdown.svg - purple M with arrow, #8a2ea5)
417
418
  const markdownSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 309 327">
418
- <path fill="#7a2491" opacity="1" stroke="none" d="m 138.68393,230.48651 c 36.58836,3.1e-4 72.68422,3.1e-4 108.78008,3.1e-4 0.13988,0.49669 0.0821,0.12537 -3.34406,3.81472 -27.34165,24.16766 -54.43119,49.41695 -81.72391,73.62893 -2.65146,2.35216 -4.5582,3.21609 -7.64686,0.37229 -26.89754,-24.76539 -75.191307,-68.40096 -80.889724,-74.12425 -0.744118,-0.74735 -1.274501,-1.57204 -2.95867,-3.69233 23.309236,0 45.299954,0 67.783144,3.3e-4 z"/>
419
- <path fill="#7a2491" d="m 61.156397,14.443673 h 69.176263 q 14.81059,56.661581 23.29958,97.452667 l 5.96036,-27.150338 q 3.61233,-15.870486 7.76652,-30.954008 l 10.6564,-39.348321 H 248.6367 L 276.09047,189.5437 H 221.90541 L 207.27544,69.137838 173.50009,189.5437 H 136.47364 L 101.07273,68.875516 86.984609,189.5437 H 35.147571 Z"/>
419
+ <path fill="#8a2ea5" opacity="1" stroke="none" d="m 138.68393,230.48651 c 36.58836,3.1e-4 72.68422,3.1e-4 108.78008,3.1e-4 0.13988,0.49669 0.0821,0.12537 -3.34406,3.81472 -27.34165,24.16766 -54.43119,49.41695 -81.72391,73.62893 -2.65146,2.35216 -4.5582,3.21609 -7.64686,0.37229 -26.89754,-24.76539 -75.191307,-68.40096 -80.889724,-74.12425 -0.744118,-0.74735 -1.274501,-1.57204 -2.95867,-3.69233 23.309236,0 45.299954,0 67.783144,3.3e-4 z"/>
420
+ <path fill="#8a2ea5" d="m 61.156397,14.443673 h 69.176263 q 14.81059,56.661581 23.29958,97.452667 l 5.96036,-27.150338 q 3.61233,-15.870486 7.76652,-30.954008 l 10.6564,-39.348321 H 248.6367 L 276.09047,189.5437 H 221.90541 L 207.27544,69.137838 173.50009,189.5437 H 136.47364 L 101.07273,68.875516 86.984609,189.5437 H 35.147571 Z"/>
420
421
  </svg>`;
421
422
 
422
423
  // Custom PDF icon (document with folded corner and red PDF banner)
@@ -453,9 +454,10 @@ const plugin: JupyterFrontEndPlugin<void> = {
453
454
  <path fill="url(#py-y)" d="M55,110c26,0,26-10,26-12V85H54v-4h37c8,0,18-5,18-26 0-23-11-27-16-27h-9v13c0,6-3,16-16,16H42c-4,0-15,2-15,14v24c0,3,0,14,28,14zM70,101c-3,0-5-2-5-5s2-5,5-5 5,2,5,5S73,101,70,101z"/>
454
455
  </svg>`;
455
456
 
456
- // Custom README icon (info icon - centered, color #912bac from info2.svg)
457
+ // Custom README icon (info icon - purple background #912bac, gray "i" #bdbdbd)
457
458
  const readmeSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
458
- <path fill="#912bac" d="m 9.5247234,11.42511 v 76.943619 l 2.1166666,2.116667 h 77.809717 l 2.116667,-2.116667 v -76.94362 l -2.116667,-2.1166662 -77.809717,0 z m 36.2115596,9.141612 h 11.01003 l 2.116667,2.116667 0,7.619586 -2.116667,2.116667 h -11.01003 l -2.116667,-2.116667 v -7.619586 z m -6.168542,19.478681 h 23.084596 l 2.116667,2.116667 v 5.700733 l -2.116597,2.09951 -3.175139,-0.02574 -2.116597,2.09951 V 67.32187 l 2.116667,2.116667 h 4.211629 l 2.116667,2.116667 v 5.567219 L 63.688967,79.23909 H 38.509408 l -2.116667,-2.116667 v -6.096386 l 2.116667,-2.116667 h 4.968955 L 45.59503,66.792703 V 52.096294 l -2.116667,-2.116667 h -3.910622 l -2.116667,-2.116667 0,-5.70089 z"/>
459
+ <rect x="9.5" y="9.3" width="82" height="81.2" rx="4" fill="#912bac"/>
460
+ <path fill="#bdbdbd" d="m 45.736283,20.566722 h 11.01003 l 2.116667,2.116667 0,7.619586 -2.116667,2.116667 h -11.01003 l -2.116667,-2.116667 v -7.619586 z m -6.168542,19.478681 h 23.084596 l 2.116667,2.116667 v 5.700733 l -2.116597,2.09951 -3.175139,-0.02574 -2.116597,2.09951 V 67.32187 l 2.116667,2.116667 h 4.211629 l 2.116667,2.116667 v 5.567219 L 63.688967,79.23909 H 38.509408 l -2.116667,-2.116667 v -6.096386 l 2.116667,-2.116667 h 4.968955 L 45.59503,66.792703 V 52.096294 l -2.116667,-2.116667 h -3.910622 l -2.116667,-2.116667 0,-5.70089 z"/>
459
461
  </svg>`;
460
462
 
461
463
  // Get SVG content from VSCode icons
@@ -493,6 +495,9 @@ const plugin: JupyterFrontEndPlugin<void> = {
493
495
  const pythonPackageSvg =
494
496
  '<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>';
495
497
  const pythonPackageDataUri = `data:image/svg+xml;base64,${btoa(pythonPackageSvg)}`;
498
+ const venvSvg =
499
+ '<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>';
500
+ const venvDataUri = `data:image/svg+xml;base64,${btoa(venvSvg)}`;
496
501
 
497
502
  // Inject CSS that overrides icons for .py and .md files
498
503
  // Note: Jupytext marks .py and .md files as type="notebook", so we need to
@@ -697,6 +702,22 @@ const plugin: JupyterFrontEndPlugin<void> = {
697
702
  background-repeat: no-repeat;
698
703
  background-position: center;
699
704
  }
705
+
706
+ /* Override venv folder icons (.venv, venv, .env, env) */
707
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon svg,
708
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon img {
709
+ display: none !important;
710
+ }
711
+ .jp-DirListing-item[data-venv-folder] .jp-DirListing-itemIcon::before {
712
+ content: '';
713
+ display: inline-block;
714
+ width: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
715
+ height: calc(var(--jp-ui-font-size1, 13px) * var(--jp-custom-icon-scale, 1.5));
716
+ background-image: url('${venvDataUri}');
717
+ background-size: contain;
718
+ background-repeat: no-repeat;
719
+ background-position: center;
720
+ }
700
721
  `;
701
722
 
702
723
  // Add CSS to make JavaScript and .env icons less bright
@@ -720,78 +741,72 @@ const plugin: JupyterFrontEndPlugin<void> = {
720
741
  }
721
742
  `;
722
743
 
723
- // Cache for Python package folder checks
724
- const pythonPackageCache: Map<string, boolean> = new Map();
725
-
726
- // Folders to exclude from Python package checking
727
- const excludedFolderPatterns = [
728
- /^\.git$/,
729
- /^\.hg$/,
730
- /^\.svn$/,
731
- /^\.venv$/,
732
- /^venv$/,
733
- /^\.env$/,
734
- /^env$/,
735
- /^node_modules$/,
736
- /^__pycache__$/,
737
- /^\.ipynb_checkpoints$/,
738
- /^\.pytest_cache$/,
739
- /^\.mypy_cache$/,
740
- /^\.ruff_cache$/,
741
- /^\.tox$/,
742
- /^\.nox$/,
743
- /^\.coverage$/,
744
- /^htmlcov$/,
745
- /^dist$/,
746
- /^build$/,
747
- /^\.eggs$/,
748
- /\.egg-info$/,
749
- /\.dist-info$/
750
- ];
751
-
752
- const isExcludedFolder = (name: string): boolean => {
753
- return excludedFolderPatterns.some(pattern => pattern.test(name));
754
- };
744
+ // Cache for Python package detection (per directory)
745
+ let pythonPackagesCache: { path: string; packages: Set<string> } | null =
746
+ null;
755
747
 
756
- // Check if a folder is a Python package (contains __init__.py)
757
- // Use the file browser's model to get the correct path
758
- const checkPythonPackage = async (
759
- folderName: string
760
- ): Promise<boolean> => {
761
- // Skip excluded folders
762
- if (isExcludedFolder(folderName)) {
763
- return false;
764
- }
748
+ // Detect Python packages by parsing pyproject.toml or setup.py
749
+ const detectPythonPackages = async (): Promise<Set<string>> => {
750
+ const currentPath = defaultFileBrowser?.model.path || '';
765
751
 
766
- // Get the current path from the file browser model
767
- if (!defaultFileBrowser) {
768
- return false;
752
+ // Return cached result if same directory
753
+ if (pythonPackagesCache?.path === currentPath) {
754
+ return pythonPackagesCache.packages;
769
755
  }
770
- const currentPath = defaultFileBrowser.model.path;
771
- const fullPath = currentPath
772
- ? `${currentPath}/${folderName}`
773
- : folderName;
774
756
 
775
- if (pythonPackageCache.has(fullPath)) {
776
- return pythonPackageCache.get(fullPath)!;
777
- }
757
+ const packages = new Set<string>();
758
+
778
759
  try {
779
- // Use JupyterLab's contents manager with the correct path from file browser
780
- const contents = app.serviceManager.contents;
781
- const model = await contents.get(fullPath, { content: true });
782
- const hasInit =
783
- model.content?.some((item: any) => item.name === '__init__.py') ||
784
- false;
785
- pythonPackageCache.set(fullPath, hasInit);
786
- return hasInit;
760
+ const contents = await app.serviceManager.contents.get(currentPath, {
761
+ content: true
762
+ });
763
+ const files = contents.content || [];
764
+
765
+ // Check for pyproject.toml
766
+ const hasPyproject = files.some(
767
+ (f: any) => f.name === 'pyproject.toml'
768
+ );
769
+ if (hasPyproject) {
770
+ const pyprojectPath = currentPath
771
+ ? `${currentPath}/pyproject.toml`
772
+ : 'pyproject.toml';
773
+ const file = await app.serviceManager.contents.get(pyprojectPath);
774
+ const content = file.content as string;
775
+ const parsed = parsePyprojectToml(content);
776
+ parsed.forEach(p => packages.add(p));
777
+ }
778
+
779
+ // Check for setup.py
780
+ const hasSetupPy = files.some((f: any) => f.name === 'setup.py');
781
+ if (hasSetupPy) {
782
+ const setupPath = currentPath
783
+ ? `${currentPath}/setup.py`
784
+ : 'setup.py';
785
+ const file = await app.serviceManager.contents.get(setupPath);
786
+ const content = file.content as string;
787
+ const parsed = parseSetupPy(content);
788
+ parsed.forEach(p => packages.add(p));
789
+ }
787
790
  } catch {
788
- pythonPackageCache.set(fullPath, false);
789
- return false;
791
+ // Ignore errors - no packages detected
790
792
  }
793
+
794
+ pythonPackagesCache = { path: currentPath, packages };
795
+ return packages;
791
796
  };
792
797
 
798
+ // Invalidate cache on directory change
799
+ if (defaultFileBrowser) {
800
+ defaultFileBrowser.model.pathChanged.connect(() => {
801
+ pythonPackagesCache = null;
802
+ });
803
+ }
804
+
793
805
  // Add a MutationObserver to mark special files in the file browser
794
- const markSpecialFiles = () => {
806
+ const markSpecialFiles = async () => {
807
+ // Get Python packages for current directory (cached)
808
+ const pythonPackages = await detectPythonPackages();
809
+
795
810
  // Process ALL items - clear wrong attributes and set correct ones
796
811
  const allItems = document.querySelectorAll('.jp-DirListing-item');
797
812
  allItems.forEach(item => {
@@ -889,24 +904,38 @@ const plugin: JupyterFrontEndPlugin<void> = {
889
904
  fileType === 'directory' ||
890
905
  item.classList.contains('jp-DirListing-directory');
891
906
  if (isDir) {
892
- // Check if this folder is a Python package (async)
893
- // Pass just the folder name - checkPythonPackage gets current path from file browser
894
- checkPythonPackage(name).then(isPythonPackage => {
895
- if (isPythonPackage) {
907
+ // Check if folder is a venv folder (.venv, venv, .env, env)
908
+ const venvNames = ['.venv', 'venv', '.env', 'env'];
909
+ if (venvNames.includes(nameLower)) {
910
+ item.setAttribute('data-venv-folder', 'true');
911
+ item.removeAttribute('data-python-package');
912
+ } else {
913
+ item.removeAttribute('data-venv-folder');
914
+ // Check if folder name matches a detected Python package
915
+ if (pythonPackages.has(name)) {
896
916
  item.setAttribute('data-python-package', 'true');
897
917
  } else {
898
918
  item.removeAttribute('data-python-package');
899
919
  }
900
- });
920
+ }
901
921
  } else {
902
922
  item.removeAttribute('data-python-package');
923
+ item.removeAttribute('data-venv-folder');
903
924
  }
904
925
  });
905
926
  };
906
927
 
907
- // Watch for changes in the file browser
928
+ // Debounce timeout for MutationObserver
929
+ let markSpecialFilesTimeout: ReturnType<typeof setTimeout> | null = null;
930
+
931
+ // Watch for changes in the file browser (debounced)
908
932
  const observer = new MutationObserver(() => {
909
- markSpecialFiles();
933
+ if (markSpecialFilesTimeout) {
934
+ clearTimeout(markSpecialFilesTimeout);
935
+ }
936
+ markSpecialFilesTimeout = setTimeout(() => {
937
+ markSpecialFiles();
938
+ }, 100);
910
939
  });
911
940
 
912
941
  // Start observing when the file browser is ready
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
+ }