Creating an Enhanced File Search Dialog (Part 1)

Sign in to queue

Description

Do you think remembering multi-part paths are an antiquated way of managing files?  Are you tired of trying to tell your friends and family how to navigate Windows Explorer to find where they may have saved that file?  See how to leverage the Windows Desktop Search (WDS) capabilities to create a new file search/open dialog.
Arian's Blog

Difficulty: Intermediate
Time Required: 1-3 hours
Cost: Free
Software: Visual Basic or Visual C# Express Editions, Windows Desktop Search 3.0 for XP (built-in to Vista)
Hardware: None
Download:

Introduction

After writing my prior article, Searching the Desktop, I decided to keep going with the whole search thing.  Entering a filename to see matching results is pretty cool, but I felt that it could get even better.  Unfortunately, in the time I spent refining my concept, I probably could have written three articles!  I get too wrapped up in the code sometimes.  Well, hopefully it's to your benefit!

The code has a number of extension points, and even a few caveats at this point, but it's still relatively solid overall.  Keep reading to find out what it does, how it works, and how you might use it in your own applications.

Code for this article is available in both C# and VB.  If you haven't already done so, download either version of Visual Studio 2005 Express Edition.  The sample will run on either XP or Vista, but on XP it will require the WDS 3.0 installation, available as a stand-alone download.

Another way to search

Trying to find files can be difficult.  We techies typically have a pretty good system in place.  I like to use folders as they were intended -- as a hierarchical mechanism.  For instance, a folder for work, then specific client folders, then project folders.  My music is similarly organized: artist folders holding album folders holding music files.  Less tech-literate folks tend to lump files together in a small number of folders.  A new folder might be created for something, but it's not rigidly enforced.  Thus, five different folders on the computer might have Word documents or music, with no distinct reason for looking in one folder over another.  When it comes to locating a file, they may not be able to remember which folder it's in, or it's exact filename.

This has made me decide that file browsing needs to be more flexible.  A famous media player (which shall go unnamed) made the interesting choice of creating a three-attribute criteria selection, then showing the matching media files:

Figure 1: Attractive (but unnamed) user-interface for browsing files

This got me to thinking: what if you could choose different attributes, and actually extend the idea beyond music files.  Windows Desktop Search (WDS) exposes a huge amount of information, so why not use that to build up the attribute lists.  The idea is pretty basic, but it turns out to be challenging to get it right!

I created a very basic custom control, called AttributeFilter to display an attribute, and all possible values for that attribute from the search index.  For example, the attribute System.Music.Genre contains 59 unique values on my system.  One AttributeFilter control would show one attribute and its values.  So far, it's similar to the aforementioned media library application, but it goes a step further.  The attribute name is actually a ComboBox control.  Now you can change to a different attribute, and the values in the ListBox control will change accordingly.

Figure 2: The AttributeFilter custom control

By chaining a few of these together, you start to get the right effect.  They need to be linked though.  Each one has the concept of a parent attribute (using the ParentAttribute property).  When the parent changes its attribute or selected value, it needs to cascade.  For instance, if I change the value for the Artist attribute to "Dave Matthews Band" I want the Album attribute to only show values that relate to that artist instead of the entire list of albums in my collection.

Constructing a Query

Essentially, what you have is a SQL query that gets built up via each AttributeFilter control.  So in the above example, I've just added a condition of "WHERE System.Music.Artist = 'Dave Matthews Band')".  If I then select "Busted Stuff" for Album (System.Music.AlbumTitle), I've appended "AND System.Music.AlbumTitle = 'Busted Stuff'.

In theory anyway.  As it turns out, the recommended way to perform this search is with:

CONTAINS(System.Music.AlbumTitle, '"*Busted Stuff*"')

You can certainly use "=" (equals), LIKE, or CONTAINS and they all work.  The actual query being built up is:

SELECT {0} FROM systemindex {1} ORDER BY {0}

Recall from my last article that "systemindex" is the virtual table used for querying your search index.  There are three replacement values here (though one is used twice).  Value #0 is the the list of columns.  This is only ever one value in this query, specifically the attribute whose value we need for the list.  This value is reused for the ORDER BY clause to sort for us.  Value #1 is the list of criteria.  In the event that no criteria is selected, nothing will appear in the query string, otherwise a WHERE clause will be added.  A final query may look like this:

SELECT System.Music.AlbumTitle
FROM systemindex
WHERE CONTAINS(System.Music.Artist, '"Dave Matthews Band"')
  and CONTAINS(System.Kind, 'Music')
  and System.Music.AlbumTitle is not null
ORDER BY System.Music.AlbumTitle

Notice the "System.Kind" attribute.  Recall from the previous article, that files in WDS are marked as being of one kind or another (one or many).  This is more broad than a file format.  A System.Kind of "Music" may encompass MP3, WAV, WMA, and other formats.  Including this in the mix is probably not always necessary as certain attributes should only occur in certain types of files (you probably wouldn't have a Word document with AlbumTitle set...), but it's a good idea to stay on the safe side.

The actual code to piece together the query is lengthy and even messy as it takes into account so many variables:

C# Code

public void UpdateValues()
{
    if (this.Enabled == false | _selectedAttribute == "")
    {
        attributeValueListBox.SelectedIndex = -1;
        attributeValueListBox.Items.Clear();
        return;
    }

    AttributeFilter p;
    List<string> parentClauses = new List<string>();

    p = this.ParentFilter;

    // Adds to the WHERE clause for each attribute, based on following
    // the chain of AttributeFilter objects (ParentFilter attribute).
    while (p != null)
    {
        if (p.SelectedAttribute != "" & !p.SelectedValue.StartsWith("All") & p.SelectedValue != "")
        {
            parentClauses.Add(string.Format("CONTAINS({0}, '\"{1}\"')",
                p.SelectedAttribute, p.SelectedValue.Replace("'", "''").Replace("\"", "\"\"")));
        }

        p = p.ParentFilter;
    }

    // Take into account specific type of entity
    if (_kind != string.Empty & _kind != "All")
    {
        parentClauses.Add("CONTAINS(System.Kind, '" + _kind + "')");
    }

    // Take into account any selected keywords
    if (_keywords != string.Empty)
    {
        parentClauses.Add(string.Format("CONTAINS(System.Keywords, '\"*{0}*\"')", _keywords));
    }

    // Make sure that we get the relevant attribute
    parentClauses.Add(_selectedAttribute + " is not null");

    string whereClause;
    whereClause = string.Join(" and ", parentClauses.ToArray());

    // Assuming that values have actually changed, initiate a search
    if (lastSearchClause != whereClause | lastSearchAttribute != _selectedAttribute)
    {
        pendingResult = backgroundSearcher.BeginInvoke(_selectedAttribute, whereClause, backgroundCallback, null);
        attributeValueListBox.Items.Clear();
        attributeValueListBox.Items.Add("<Loading>");

        lastSearchClause = whereClause;
        lastSearchAttribute = _selectedAttribute;

    }

}

Visual Basic

    Public Sub UpdateValues()
        If Me.Enabled = False Or _selectedAttribute = "" Then
            attributeValueListBox.SelectedIndex = -1
            attributeValueListBox.Items.Clear()
            Return
        End If

        Dim p As AttributeFilter
        Dim parentClauses As New List(Of String)

        p = Me.ParentFilter

        ' Adds to the WHERE clause for each attribute, based on following
        ' the chain of AttributeFilter objects (ParentFilter attribute).
        While Not p Is Nothing
            If p.SelectedAttribute <> "" And Not p.SelectedValue.StartsWith("All") And p.SelectedValue <> "" Then
                parentClauses.Add(String.Format("CONTAINS({0}, '""{1}""')", _
                    p.SelectedAttribute, p.SelectedValue.Replace("'", "''").Replace("""", """""")))
            End If

            p = p.ParentFilter
        End While

        ' Take into account specific type of entity
        If _kind <> String.Empty And _kind <> "All" Then
            parentClauses.Add("CONTAINS(System.Kind, '" & _kind & "')")
        End If

        ' Take into account any selected keywords
        If _keywords <> String.Empty Then
            parentClauses.Add(String.Format("CONTAINS(System.Keywords, '""*{0}*""')", _keywords))
        End If

        ' Make sure that we get the relevant attribute
        parentClauses.Add(_selectedAttribute & " is not null")

        Dim whereClause As String
        whereClause = String.Join(" and ", parentClauses.ToArray())

        ' Assuming that values have actually changed, initiate a search
        If lastSearchClause <> whereClause Or lastSearchAttribute <> _selectedAttribute Then
            pendingResult = backgroundSearcher.BeginInvoke(_selectedAttribute, whereClause, backgroundCallback, Nothing)
            attributeValueListBox.Items.Clear()
            attributeValueListBox.Items.Add("<Loading>")

            lastSearchClause = whereClause
            lastSearchAttribute = _selectedAttribute

        End If

    End Sub

Once the query is built, it double-checks to make sure it isn't the same query as last time (why waste resources?), then sends the actual query request to a background operation by calling BeginInvoke on the backgroundSearcher delegate.  BeginInvoke is very cool.  It takes whatever method is pointed to by the delegate, any required parameters, and a callback method delegate, then calls it on a background thread.  No matter how long the query takes, execution of the current thread immediately continues on the next line.  The delegate gets executed on a thread from the thread pool.  In this case, when the operation completes, it will invoke the backgroundCallback delegate:

C# Code

public void AttributesUpdatedHandler(IAsyncResult result)
{
    if (this.InvokeRequired)
    {
        this.Invoke(backgroundCallback, result);
        return;
    }

    if (result == pendingResult)
    {
        if (result.IsCompleted)
        {
            // Retrieve the asynchronous results
            List<string> items = new List<string>();
            items = (List<string>)backgroundSearcher.EndInvoke(result);

            // Update the results (attribute values) in the ListBox
            attributeValueListBox.SelectedIndex = -1;
            attributeValueListBox.Items.Clear();
            attributeValueListBox.Items.Add("All (" + items.Count + ")");
            attributeValueListBox.Items.AddRange(items.ToArray());
            attributeValueListBox.SelectedIndex = 0;

            pendingResult = null;
        }
    }
    else
    {
        // Getting here means that the result didn't match the one waited on
        backgroundSearcher.EndInvoke(result);
    }
}

Visual Basic

Public Sub AttributesUpdatedHandler(ByVal result As IAsyncResult)
        If Me.InvokeRequired Then
            Me.Invoke(backgroundCallback, result)
            Return
        End If

        If result Is pendingResult Then
            If result.IsCompleted Then
                ' Retrieve the asynchronous results
                Dim items As New List(Of String)
                items = CType(backgroundSearcher.EndInvoke(result), List(Of String))

                ' Update the results (attribute values) in the ListBox
                attributeValueListBox.SelectedIndex = -1
                attributeValueListBox.Items.Clear()
                attributeValueListBox.Items.Add("All (" & items.Count & ")")
                attributeValueListBox.Items.AddRange(items.ToArray())
                attributeValueListBox.SelectedIndex = 0

                pendingResult = Nothing
            End If
        Else
            ' Getting here means that the result didn't match the one waited on
            backgroundSearcher.EndInvoke(result)
        End If
    End Sub

This method needs to take the returned values and load them into the ListBox.  It's not that simple though, for reasons of cross-threading limitations.  If you just start adding items to the ListBox, you'll get an ugly exception since it's still running on the background thread -- not the UI thread.  Instead, see if Invoke is required , and if so, call the form's Invoke method with the exact same method to dispatch it to the UI thread.  When it runs again, it does so via the UI message pump.  Make sense?  It's an unfortunate limitation, but we're stuck with it.  Once we're on the right thread, it double-checks the result to be sure that it's the one it's waiting on.  If the user changes attributes a few times, we only need to update the most recent values.  Call EndInvoke to retrieve the return value of the operation (you must ALWAYS call EndInvoke for each BeginInvoke), clear the list, add the "All" item, then the actual values.  The actual list update is pretty simple once everything is setup!

After selecting the search criteria, you will see a view like this (depending on your own music collection, of course!):

Figure 3: Browsing the Collection

As attributes are updated, the changes cascade to the other attribute lists, and also down to the file list.  The file list pulls back all relevant columns/attributes based on the Kind selected in the upper-left hand ComboBox control.  If you double-click a file (or single-click and click OK), the dialog will close and the path will show up in the original form.

Using the Sample Application

The application is useable at this point, but still in need of some work to really optimize performance (and probably code organization for that matter!).  I've seen attribute lookup queries take 30 seconds.  Other times they are almost instant.  Clearly not optimal.  Also, WDS doesn't allow parameterized queries, so the code is a little messier than I'd like in places.

Upon launch, the application looks like this:

Figure 4: Main form for the sample application

Look familiar?  What an odd coincidence...  Seriously though, it's a dialog many people are familiar with, so I went with it.  Click Browse to launch the browser form.  Once there's a file path in the text box (either from the browser dialog, or typing by hand), you can click OK   This will call Process.Start with the filename to launch whatever application is registered for the filetype.

Next Steps

A number of features can still use some work.  The Keywords/Name TextBox in the browser aren't fully functional yet, and the Age field only looks pretty!  I may simplify the user interface a bit, and as I finish the features, the API may morph a bit as well.  My goal is to make it as useful as the standard OpenFileDialog, only using the WDS index.  Perhaps if WDS isn't available, it can even fallback to the regular selection dialog.

Conclusion

Working with the search index is fun.  Being able to locate files using SQL queries gives you a lot of power.  Trying to do it in such a way that performance is optimal can definitely be a challenge though.  In Part 2, hopefully performance will be improved, and a few more features will really round it out.  As usual, contact me through my blog if you have any questions or comments.


Arian Kulp is an independent software developer and writer working in the Midwest.  He has been coding since the fifth grade on various platforms, and also enjoys photography, nature, and spending time with his family.  Arian can be reached through his web site at http://www.ariankulp.com.

Tags:

utility, Windows

The Discussion

Add Your 2 Cents